01 / Two controls, several rooms
Getting Studio again should keep its current setting.
For a single control, a plain channel object is enough. A second control could receive that same object directly. As rooms are opened by name, an owner can resolve the room and consistently return its existing channel. Creating another channel on each lookup would lose that shared identity.
Multiton retains an instance for each key and returns that instance on repeated lookup. The familiar presentation uses a static map and a keyed accessor. This example makes the owner explicit: the guarantee is one retained channel per room within that owner.
Two owners can each have a Studio channel, and removing an entry lets a later lookup create another one.
Choose the room.
The caller chooses which room it controls. Getting that room again does not request a reset.
Reuse or create.
A miss constructs and retains one channel. A hit returns that object with its current state.
Keep one gain setting.
A gain change updates this channel. Other room entries keep their own settings.
Gain is an integer setting from 0 through 100, and the meters draw that setting rather than measured sound.
02 / See the mechanism, then its conditions
The map remembers identity. The channel keeps its setting.
The basic view is the get method from the full implementation. Its owner fixes an initial
gain from 0 through 100 during setup. Lookup accepts exactly studio or lounge, with no trimming, case folding, or fallback key. An unknown key
fails before allocating an entry.
That keeps at most two entries in each map. A new channel starts at the owner’s initial
gain; a repeated lookup preserves the channel’s current setting. Supplying a default at
every lookup would invite conflicting configuration. A deliberate setGain call changes
an existing channel and rejects invalid values before mutation.
The get method, excerpted from the full owner. Validate the room key, look up its entry, and construct and retain only on a miss. Same owner and retained key return the same channel in every language.
get(raw: string): Channel {
const key = roomKey(raw);
let channel = entries.get(key);
if (!channel) {
// Lookup, cheap creation, and publication are synchronous; no await or callback.
channel = createChannel(key, ++created, initialGain);
entries.set(key, channel);
}
return channel;
}, func (s *RoomChannels) Get(raw string) (*Channel, error) {
key, err := roomKey(raw)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if channel, ok := s.entries[key]; ok {
return channel, nil
}
// Cheap, in-memory construction remains under the same lock as publication.
if s.entries == nil {
s.entries = make(map[string]*Channel)
}
s.created++
channel := &Channel{room: key, id: s.created, gain: s.initialGain}
s.entries[key] = channel
return channel, nil
} Reading the TypeScriptA synchronous lookup, with closure-owned state
Map retains the object reference. The miss path has no await or user callback between checking and storing. A later call in this JavaScript execution context therefore sees the stored channel. Gain validation and assignment also run synchronously.
Object.freeze fixes the returned method surface; the gain variable in its closure is intentionally mutable. This is shared state, not an immutable value. Another browser worker or server process has different memory. TypeScript checks whole, finite numbers; native integer fields still need range validation.
Reading the GoOne mutex for publication; one per channel for settings
The owner’s mutex covers lookup, cheap construction, and insertion. Each channel has another mutex for its gain setting. Different rooms briefly share the map lock, then use their separate channel locks.
Pass pointers and do not copy a used mutex. A zero RoomChannels is an empty owner with initial gain zero; the constructor validates other values. Native concurrent tests check one published identity under simultaneous lookup and safe gain updates. The final value follows whichever valid setter runs last. See Go’s Mutex contract.
Reading the PythonA dict owner, identity by reference, and locks
The owner’s dict retains one Channel per accepted room key. Its lock
covers lookup, construction, insertion, and removal; each channel has its own lock for
gain. Python’s is checks object identity, while the frozen Snapshot is only a value view.
Gain validation happens before the channel changes. with releases each lock
when its block exits. The implementation keeps construction small and local; slow I/O or
callbacks would need a different publication design.
03 / Predict, attach, and observe
Follow the held reference, not just the selected key.
Watch two controls share Studio, or step through each change. In Try it, set gain to 30 through Console, then predict what Remote reads. Attach Remote to Lounge, then to Studio in Mix B. The room key and owner both affect which instance it receives.
The selectors describe the next attachment. Setting gain uses the reference already held by that control. The retained-entry tables show what future lookups will find.
Share a channel by room.
Console holds Mix A / studio / #1, gain 80, retained. Remote holds Mix A / studio / #1, gain 80, retained. Controls hold the same instances. Mix A retains studio #1 gain 80. Mix B retains no entries.
Console
Mix A · studio #1
Retained by owner
Remote
Mix A · studio #1
Retained by owner
Both controls hold the same channel.
Mix A lookup entries
Mix B lookup entries
No entriesOne room, one retained channel.
Console and Remote both hold Mix A’s Studio channel #1.
Reduced motion: choose a scene to see its completed state.
Read this scene
Console and Remote both hold Mix A’s Studio channel #1.
Console holds Mix A / studio / #1, gain 80, retained. Remote holds Mix A / studio / #1, gain 80, retained. Controls hold the same instances. Mix A retains studio #1 gain 80. Mix B retains no entries.
04 / A retained entry is not every live reference
Forgetting is not revoking.
Open the removal experiment. Set Studio’s gain to 30, forget the Studio entry, then attach Remote to it again. Console still holds the old channel at 30. Remote receives a new one at the initial gain of 80. Both objects are alive for the same room key in this owner’s history.
The map can still contain only one entry for Studio. That is a narrower statement than “there can only be one live Studio channel.”
Silently recreating entries can leave two controls for the same room adjusting different objects, and a size cap or timeout does not by itself make that replacement safe.
When entries own sockets, timers, or other resourcesMemory ownership and operational lifetime are separate
Deleting a map entry removes one reference. Whether and when cleanup occurs depends on the resource and ownership design. Another live reference can keep the allocation alive; cleanup associated with Drop waits for final ownership release. That does not by itself define safe cancellation or shutdown.
A resource owner may need to stop new work, wait for active users, close the resource, and only then permit replacement. Calling close can also make an object unusable while references remain.
05 / A map cannot repair an incomplete key
“Same key” must mean “should share.”
Our two room names are unique only within one venue. A real system may have another identity dimension. Adding a mutex would not fix callers being grouped under the wrong key.
Reasoning and a stopping pointAlso available without JavaScript
A shared owner per venue or a complete venue/room key can both preserve the distinction. The first makes the venue an ownership boundary; the second makes it part of key equality. A short-name-only global key cannot distinguish the two venues.
Rebuilding an owner per lookup loses the shared channel state. Evicting an active entry can do the same while older handles remain.
If only one caller needs the channel, pass it a plain fresh instance.
Which callers should share? What must the key distinguish? Who owns the map, and what happens to an already-held handle when an entry is removed?
This reflection is not saved or automatically assessed.06 / Give the design a place to live
Bootstrap chooses the owner; controls use its channels.
Opening a mixing session creates one RoomChannels owner. The application resolves a room, obtains its channel, and passes that handle to the Console or Remote control. The controls need only the channel interface. A test can supply a fresh owner without changing another session’s settings.
Keeping the owner for the mixing session preserves sharing. Constructing it inside every control would create independent settings. Keeping it forever makes the lifetime an application policy. Neither choice follows from using a map.
Here, Console and Remote are two callers inside one process. Controls on separate devices would need a protocol and an authority for synchronizing settings.
| Change | Decision it requires |
|---|---|
| Open a connection asynchronously | Coordinate in-flight initialization, publish once, and define failure/retry behavior. |
| Accept arbitrary room keys | Decide admission bounds and lifecycle. Rejecting a new key can be safer than evicting a channel still held by a control. |
| Change configuration for an existing key | Reject a mismatch, version the identity, or perform coordinated replacement. Do not silently let lookup order decide. |
| Run an isolated test | Create a fresh owner and inject its channel; do not clear a shared global used by other tests. |
Why this construction happens under a lockA small operation with a deliberate boundary
These constructors only create a small in-memory settings object. Doing that while holding the map mutex makes first lookup and publication one protected operation. Slow I/O or a constructor that calls back into the owner would need a different design.
For asynchronous TypeScript creation, an await between checking the map and storing a result allows another caller to observe the same miss. Retaining an in-flight promise can coordinate callers, but then rejection, retry, cancellation, and removal of that exact generation need explicit rules.
Build UIs?Every service worker update reaches its caches by name, and one day your split view will need one model per note.
Where it already is in your components
You probably already follow this rule if you have written a service worker: put the
version in the cache name, and delete every other cache when the new worker activates.
SvelteKit’s service worker example does both, starting from const CACHE = `cache-${version}`, and the Service Worker spec explains that caches “do not disappear just because the service worker script is updated,” so
“authors should version their caches by name.” The reason is this lesson’s lookup, run by the
browser.
caches.open(name) is a keyed accessor. The browser keeps one map from names
to caches for an origin’s storage, shared by every tab and worker on that origin. A miss creates and retains an empty cache; a hit returns the cache with whatever it already holds.
Each call gives you a new Cache object, but objects opened with the same name reach
the same entries: in Chromium, a response one tab stored was there when a second tab opened
that name, and a page on another origin saw no caches at all. The origin is the owner, and the
name is the whole key.
Now keep the name fixed at 'cache' and deploy. The new worker installs while
the old one still controls your open tabs, and its install step opens the
same name, so it writes the new deploy’s files into the cache the old worker is serving
from. In our Chromium run, with the new worker still waiting, a tab on the old version
asked for /app.js and got the new deploy’s file. The activate cleanup keeps
that cache too, because its name still matches. With the version in the name, the same tab kept
the old file until every tab had closed; the next tab got the new one, and only the new cache
remained. That is section 05’s rule on the platform: a key that leaves out the deploy merges
two deploys that should not share.
Deleting a cache is section 04’s detachment. After caches.delete(name), the spec says the
currently referenced Cache objects “should remain functional”: in Chromium, a
tab holding the old one could still read and write it, while the next caches.open of that name returned an empty cache.
When you have to own it
Now the map is yours. Your notes app gets a split view, and a writer opens the same note in both panes. Typing on the left should show up on the right, and undo in either pane should walk one history. If each pane loads the note into its own component state, the panes disagree after the first keystroke, and whichever one saves last wins.
Keep one document model per note in a workspace, and let each pane ask the workspace for its note. Choose the whole key: the note’s ID, not its path, so renaming it in one pane does not hand the other pane a second model, and not its title, which two notes can share. Create the workspace for the signed-in account near the root and pass it down through context, so switching accounts starts from an empty map. Then decide the lifetime. Closing one pane must not forget the note while the other still shows it, or the next open creates a second model beside the one on screen, just as Remote got a new Studio while Console kept the old one. Count the panes holding each note, or forget a note only when the writer closes it from the file list.
07 / Recognize the shape and its limits
Named access is familiar. Its contract still matters.
Firebase’s JavaScript app API supports initializing named apps and retrieving an app with getApp(name). A missing name throws; initialization with an existing name and different configuration also throws. See initializeApp and getApp.
Its deleteApp promises to make the app unusable and free associated service resources. Our forget method only detaches an entry, a much smaller lifecycle contract.
Go’s sync.Map.LoadOrStore accepts an already-evaluated value and chooses the existing or supplied entry. If each caller constructs that value first, more than one construction may happen even though one value is retained. Our locked miss path controls both steps. The distinction is construction versus publication.
08 / Take the idea with you
Which callers should share this exact instance?
Explain the rule without saying “Multiton”: within this owner, these keys select these shared instances, for this lifetime. Then describe what happens to a caller that holds an old reference after removal.
Use keyed retention when callers need a common object identity and state for a meaningful key. Decide who owns the map, what configuration belongs to that identity, how the set of entries is bounded, and what ends its lifetime.
For a fixed handful of known objects, bootstrap can create and inject them directly. A keyed accessor earns its place when identity lookup and retention are responsibilities callers should not repeat.
Connections to follow nextRelated lessons
Singleton is the one-slot case of the same
idea: createBudgetAccess retains a single budget, and every caller shares its mutable
state. Keyed retention is Singleton generalized to one instance per key, asking the same ownership
questions once per room. It is not in the original GoF catalog.
Registry stores what a contributor already
built: register('plain', plainText) files an existing function under a name,
and lookup hands that function back without calling it. Here the owner constructs on a
miss, and the stored thing is a stateful channel whose identity and current gain matter. Factory centralizes the creation decisions and may
return a fresh object each time; retention is the added promise.
Flyweight uses the same code shape. StyleBook.get looks up a key, creates on a miss, and retains the result
exactly as RoomChannels.get does. The difference is the promise, not the
code: a style is frozen intrinsic data that many places read, while a channel is shared
mutable state that Console and Remote both change. Object pool leases reusable instances and tracks availability; a Multiton lookup here allows both callers
to hold the same channel at once.
The Memoization lesson concerns reusing computed results. A memo table keyed by room could be rebuilt without anyone noticing. This channel could not: Console is still holding the old one, so recreating it changes behavior even though there is no resource to close.