Where Do Settings Live?
Designing User Preferences for a Multi-Region World
"I changed it yesterday. Why is it back?"
Every product team hears this sentence eventually. It usually arrives as a vague bug report, gets labelled "can't reproduce" and comes back three sprints later with a longer email thread attached. Behind it there's almost never a single broken line of code. Usually a team simply never answered one question:
Where does this setting live and who is allowed to change it?
This article is about answering that question on purpose, using a fictional but very realistic product, the bugs it ran into and the design rules that made those bugs go away for good.
Meet "Fieldbook"
Fieldbook is an imaginary web app used by service teams around the world. Technicians use it to look up jobs, estimate effort and export reports. It's used in dozens of countries, it's available in many languages and it runs both standalone and embedded inside a larger partner portal.
Users can personalise a few things:
| Setting | Example values | Why it matters |
|---|---|---|
| Unit system | Metric / Imperial | Distances, weights, torque values |
| Effort scale | Hours / Work units | Some regions bill in standardised units, not hours |
| Report format | PDF / CSV / Legacy CSV | Downstream tools in each country expect different files |
| Remember my choices | On / Off | Whether any of the above should persist at all |
Four settings. A form, a save button, a database table. Simple, right?
The Bugs
Within a few releases, the support inbox told a different story:
- Settings overwrote each other. Changing the effort scale quietly reset the report format. Each screen loaded the whole preferences object, changed one field and wrote everything back, including stale values.
- Settings were wrong after switching regions. A technician working at sites in two countries had to re-pick his effort scale every time he switched. One value was stored per user, but the setting really depended on the user in a region.
- Settings couldn't be changed, with no explanation. In embedded mode, the partner portal passed a required value through the URL. The user could still edit the dropdown, the change was ignored on the next load and it then leaked into standalone mode.
- "Remember my choices" didn't remember. Turning persistence back on saved nothing until some other setting changed, because saves only fired on individual change events.
- The app wouldn't start. Loading preferences was part of startup and the app waited for it. When the preferences service was slow inside the embedded frame, users got a blank screen.
Five different symptoms, but the same root cause behind all of them: nobody had defined the scope, source and authority of each setting.
The Three Questions Every Setting Must Answer
For every preference you add, write down the answers to these:
- Scope: Whose setting is it? The user's, the user's within a region, the device's, or the organisation's?
- Source: Where can the value come from? A saved preference, a URL parameter, a host application, a regional default, a hard-coded fallback?
- Authority: Who wins when two sources disagree?
If you can't answer all three, you haven't designed the setting yet. You've only built a checkbox.
The Fixes: Five Design Rules
1. A save touches only what the user changed
Replace read-modify-write of the whole object with partial updates and keep unrelated settings from sharing a save action.
// Before: overwrites every field with whatever this screen loaded earlier
await api.put('/preferences', { ...current, effortScale: 'hours' });
// After: send only the delta
await api.patch('/preferences', { effortScale: 'hours' });
2. Model the real scope
If a setting varies by region, the region belongs in the key:
{
"userId": "u-456",
"global": { "unitSystem": "metric" },
"byRegion": {
"DE": { "effortScale": "units", "reportFormat": "csv" },
"AT": { "effortScale": "hours", "reportFormat": "pdf" }
}
}
| If the setting depends on… | Scope it to… | Example |
|---|---|---|
| Personal taste | User (global) | Dark mode, language |
| Local business rules | User × Region | Billing scale, report format |
| The physical device | Device (local storage) | Panel sizes, last-opened tab |
| Company policy | Organisation | Allowed export types |
3. Separate the value from its source
Resolve every setting through clear layers and remember which layer won:
┌──────────────────────────────────────┐
│ 1. Locked by host / URL (read-only) │ ← highest authority
├──────────────────────────────────────┤
│ 2. Saved user × region preference │
├──────────────────────────────────────┤
│ 3. Saved user (global) preference │
├──────────────────────────────────────┤
│ 4. Regional default │
├──────────────────────────────────────┤
│ 5. Application default │ ← always present
└──────────────────────────────────────┘
If the value came from a lock, show the control disabled with a short explanation ("Set by your organisation's portal") and never persist it as if the user chose it. When the lock goes away, the user's own choice comes back untouched.
4. Treat persistence toggles as state machines
| Transition | Action |
|---|---|
| Off → On | Persist the current effective values immediately |
| On → Off | Stop writing and decide on purpose whether to keep or clear stored values |
| On → On (any change) | Patch only the changed field (Rule 1) |
Put the toggle behind a feature flag so you can roll it out gradually and switch it off without a redeploy.
5. Preferences enhance the app. They never block it.
Render immediately with defaults, apply saved values when they arrive and time-box the request.
initialize() {
this.preferences.set(defaultsFor(this.region)); // render immediately
this.api.getPreferences().pipe(timeout(3000)).subscribe({
next: saved => this.preferences.set(merge(defaultsFor(this.region), saved)),
error: () => this.log.warn('Preferences unavailable, using defaults'),
});
}
Don't Forget the Humans
Translate the explanations, not only the labels. A "Set by your organisation" tooltip only helps if it's in the user's language. Ship every new string (label, help text, locked-state message, release note) to every supported locale in the same change.
Tell users what changed. A one-line release note like "Your settings are now saved separately for each region you work in" prevents a wave of "why is it different now?" tickets.
Testing the Gaps
Settings bugs live between features, so test those gaps directly:
- Concurrency: save A, then save B from a stale state. A survives.
- Scope: save in region X, switch to Y, switch back. X is intact.
- Authority: load with a locked URL value. The control is disabled and nothing is persisted.
- Transitions: toggle persistence off → on. Effective values are saved.
- Resilience: fail or time out the preferences service. The app still renders.
Closing Thought
User preferences look like the most boring part of an application. In a product used across regions, languages and host platforms, they turn out to be a small distributed system with scopes, sources, authorities and failure modes.
So the next time someone says "I changed it yesterday, why is it back?", don't start with the code. Start with the question:
Where does this setting live and who is allowed to change it?
If you've fought your own "it reset itself" battles, I'd love to hear how you solved them.
Top comments (0)