DEV Community

Shambhavi Chavan
Shambhavi Chavan

Posted on

Where Do Settings Live?

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:

  1. Scope: Whose setting is it? The user's, the user's within a region, the device's, or the organisation's?
  2. Source: Where can the value come from? A saved preference, a URL parameter, a host application, a regional default, a hard-coded fallback?
  3. 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' });
Enter fullscreen mode Exit fullscreen mode

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" }
  }
}
Enter fullscreen mode Exit fullscreen mode
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
└──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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'),
  });
}
Enter fullscreen mode Exit fullscreen mode

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)