← Concepts & practices
Concept Design principles and language mechanisms

Idempotence

Doing it twice should do nothing new.

You already count on Set.add ignoring a value it has. Let’s follow a chat app’s Mute button and unread badge, written the obvious way, until a double-click leaves a channel unmuted and one message shows up as two.

TypeScriptGo One set of channel settings, two implementations.

01 / The idea

A toggle and a counter are a fair start.

You’re building a team chat app. Each channel has a Mute button, and toggleMute(muted, channel) flips the setting. Each channel also has an unread badge, and countDelivered(counts, channel) adds one when a message arrives. Both are one line, and both are right as long as every click and every delivery happens exactly once.

Read the first versionTypeScript · the version this lesson starts from
settings.ts
// The first version: the Mute button flips the setting, and each delivered message adds one.
export function toggleMute(muted: Muted, channel: string): Muted {
	return { ...muted, [channel]: !muted[channel] };
}

export function countDelivered(counts: Counts, channel: string): Counts {
	return { ...counts, [channel]: (counts[channel] ?? 0) + 1 };
}

Go’s ToggleMute and CountDelivered clone the map and flip or add the same way. Both languages meet again at setMuted in section 02.

Then the app meets a real network. On a slow train someone clicks Mute, sees nothing happen, and clicks again: the channel ends up unmuted. A save times out after the server already applied it, the app retries, and the toggle flips back. The message server can’t tell whether a phone received message m-7, so it sends it again, and the badge says 2 for one message.

An operation is idempotent when applying it twice leaves the same state as applying it once. The first application can change a lot; every repeat changes nothing. You get there by describing the result instead of the step: “muted is true” instead of “flip it”, “m-7 is unread” instead of “add one”. HTTP puts it in terms of requests: a method is idempotent “if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.”

Section 05 builds a Mute button and a read marker that survive retries and React’s development remount, in React and Svelte.

02 / See the shape

Say the result you want, and name the things you count.

The basic form replaces the toggle with setMuted and adds a check for the property itself. In the wild keeps unread messages as a set of IDs with a read position. At the call site runs a double-click and a redelivery through both versions.

Both languages produce the same states and the same verdicts.

Set the state you want. setMuted replaces a toggle, and sameAfterTwice checks the property: applying twice leaves what applying once does.

TypeScriptReading
settings.ts
// Say the state you want. Setting it again changes nothing.
export function setMuted(muted: Muted, channel: string, value: boolean): Muted {
	return { ...muted, [channel]: value };
}

// An operation is idempotent when applying it twice leaves the same state as applying it once.
export function sameAfterTwice<State>(state: State, apply: (state: State) => State): boolean {
	const once = apply(state);
	return JSON.stringify(apply(once)) === JSON.stringify(once);
}
GoAlongside
settings.go
// SetMuted says the state you want. Setting it again changes nothing.
func SetMuted(muted Muted, channel string, value bool) Muted {
	next := maps.Clone(muted)
	if next == nil {
		next = Muted{}
	}
	next[channel] = value
	return next
}

// SameAfterTwice reports whether applying an operation twice leaves the same state as once.
func SameAfterTwice[S any](state S, apply func(S) S) bool {
	once := apply(state)
	return reflect.DeepEqual(apply(once), once)
}
Reading the TypeScriptPlain values and one small check

Every function returns a new object, so sameAfterTwice can apply an operation, apply it again, and compare the two results with JSON.stringify. That’s enough for these small, ordered records; it isn’t a general equality check.

recordUnread returns the state it was given when the ID is already there, so a repeat isn’t just equal, it’s the same object.

Reading the GoMaps, clones, and a generic check

A Go map passed to a function shares its entries with the caller, so each function clones with maps.Clone before writing, and the test checks the input is unchanged. A nil map is a fine starting state.

SameAfterTwice[S any] compares with reflect.DeepEqual, and slices.Contains and slices.Index do the ID-set work.

03 / Repeat it

Run each operation once, then again.

Five operations, each applied twice by the lesson’s functions. Every box is the state after that many runs. Before each step, guess whether the second box will match the first.

In Try it, pick an operation and run it up to four times.

Idempotence

What does doing it twice do?

Toggle mute. toggleMute(muted, 'design'). before: not muted; once: muted; 2 times: not muted. Repeating it changes the state again. Each toggle flips whatever is there, so a second click, or a retry of the first, undoes it.

01/ 05
Apply toggleMute twice

A second toggle undoes the first.

Muted after one click, not muted after two.

Reduced motion: choose a scene to see its completed state.

Read this scene

Muted after one click, not muted after two.

Toggle mute. toggleMute(muted, 'design'). before: not muted; once: muted; 2 times: not muted. Repeating it changes the state again. Each toggle flips whatever is there, so a second click, or a retry of the first, undoes it.

Watch restarts when you return. Step through keeps your selected step. Try it starts with the toggle applied twice each time you open it.

What repeat-safe operations buy you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

Retries without a second thought
A save that timed out can be sent again: { muted: true } twice is muted.
Clicks that can’t undo each other
Two clicks on “Mute” that send the wanted state leave the channel muted.
Counts of things, not of deliveries
The badge is the size of the unread set, so a redelivered m-7 is still one.
Effects that survive a re-run
Marking read up to m-7 again finds nothing left to clear.
A property you can test
sameAfterTwice checks it in one line for any operation.

The review words are idempotent, absolute update (set a value) versus relative update (flip it, add one), deduplication for ignoring an ID you already have, read marker or high-water mark for a position, and safe to retry. Section 08 covers what they don’t promise.

04 / Try a decision

A reaction that arrives twice.

Reactions were added after the unread fix, the quick way. The code is in reactions.ts, and the lesson’s tests pin what happens.

What does the message show after the retry?

Mina taps 👍 on a message. The app sends addReaction(reactions, 'mina', '👍'), which appends { user, emoji }. The server applies it, but the response is lost and the request times out, so the app retries once, and that succeeds.

05 / Give it a real job

A Mute button and a read marker on a bad connection.

In the real app, requests time out after they’ve been applied, and components run their effects more than once. The Mute button has to retry without flipping anything, and opening a channel has to mark it read however many times that code runs.

Mute

Sends the wanted state

PUT { muted }, retried with the same body.

Read

Sends a position

PUT { upTo }, the newest message seen.

Failure

Puts the old state back

Only when every attempt failed.

The example leaves out the server, requests that arrive out of order, and backoff between attempts, which Retry, backoff, and idempotency covers.

Build UIs?Every effect you write may run twice in development, and every save may be retried on a phone.

Where it already is in your components

React checks for this on purpose. Its guide says “React intentionally remounts your components in development to find bugs”, and that “The right question isn't "how to run an Effect once", but "how to fix my Effect so that it works after remounting".” The textbook pair of Mute and Unmute buttons each send the state they name, so clicking Mute twice or retrying can’t undo it.

The same guide notes that after a remount, “In production, there will only be one request.” Idempotence is what makes the development double harmless rather than something to hide.

When you have to own it

Now it’s the channel view. Its effect marks the channel read up to the newest message whenever it runs: on mount, on React’s development remount, and in Svelte whenever the newest message changes. Each run sends a position, so an extra run sends the same position again and nothing changes on the server.

The mute checkbox sends the checked value the control reports and goes through saveMuted, which sends the same body again after a network error or a server error. If the first attempt did land, the retry sets what’s already set.

channel-client.ts
// Requests that say the state they want, so sending one again is safe.
export async function saveMuted(
	fetchFn: typeof fetch,
	channel: string,
	muted: boolean,
	attempts = 3
): Promise<'saved' | 'failed'> {
	const body = JSON.stringify({ muted });
	for (let attempt = 1; attempt <= attempts; attempt++) {
		try {
			const response = await fetchFn(`/api/channels/${channel}/settings`, {
				method: 'PUT',
				headers: { 'content-type': 'application/json' },
				body
			});
			if (response.ok) return 'saved';
			// A 4xx will fail the same way again; only a server error is worth another try.
			if (response.status < 500) return 'failed';
		} catch {
			// The request may or may not have reached the server. Sending the same body again is safe.
		}
	}
	return 'failed';
}

// A read marker is a position, so marking the same message read again changes nothing.
export async function markRead(fetchFn: typeof fetch, channel: string, messageId: string) {
	await fetchFn(`/api/channels/${channel}/read-marker`, {
		method: 'PUT',
		headers: { 'content-type': 'application/json' },
		body: JSON.stringify({ upTo: messageId })
	});
}

Mute and Unmute buttons that each send the state they name, so a double click on Mute stays muted; the old state comes back if every attempt fails.

ReactAlready in your code
MuteButton.tsx
import { saveMuted } from './channel-client';

export function MuteButton({
	channel,
	muted,
	onChange
}: {
	channel: string;
	muted: boolean;
	onChange: (muted: boolean) => void;
}) {
	// Each button carries the state it asks for. Clicking "Mute" twice sends { muted: true }
	// twice, so a double click or a retry can't undo it. A single toggle would compute !muted.
	async function choose(next: boolean) {
		const previous = muted;
		onChange(next);
		if ((await saveMuted(fetch, channel, next)) === 'failed') onChange(previous);
	}

	return (
		<div role="group" aria-label={`Notifications for #${channel}`}>
			<button type="button" aria-pressed={muted} onClick={() => choose(true)}>
				Mute #{channel}
			</button>
			<button type="button" aria-pressed={!muted} onClick={() => choose(false)}>
				Unmute
			</button>
		</div>
	);
}

06 / Recognize it elsewhere

Anywhere a set sits next to a step.

You’ve used both halves of each pair. For each one, ask what a second call does.

Familiar operations that are safe to repeat, and their counterparts that aren’t
Where you’ve seen itSafe to repeatNot safe to repeat
Assignmentmuted = truecount += 1
Collectionsset.add(id)array.push(id)
CSS classesclassList.add('open')classList.toggle('open')
HTTP methodsPUT, DELETE, GETPOST, PATCH
MessagesUnread as a set of IDsA counter per delivery

MDN says Set.prototype.add “inserts the specified value into this set, if it is not already present”, and classList.add adds tokens, “omitting any that are already present.” When an operation starts from what’s there and moves it, a second call moves it again.

07 / Already in your toolbox

HTTP and your framework already assume it.

Three places to look. For each one, find what’s allowed to repeat and why.

RFC 9110 · Idempotent methods

The definition, which methods are idempotent, and why they matter: such a request can be repeated automatically after a communication failure.

Read the section ↗

MDN · Idempotent

A short glossary entry, with POST adding rows and DELETE returning different status codes for the same effect.

Read the entry ↗

React · Synchronizing with Effects

Why components remount in development, and how to write effects that behave the same when they run again.

Read the guide ↗
A useful counterexample: sending a messageWhen the effect is a new thing each time

Pressing Send twice on purpose should send two messages. There’s no state to describe; each call is supposed to make something new. To make a retried send safe, the app gives the message an ID before sending it, and the server remembers the IDs it has accepted. Idempotency and at-least-once delivery covers those keys and receipts.

08 / The parts to watch

Idempotence promises less than it sounds like.

These are the places it still goes wrong.

It doesn’t put requests in order

Mute, then Unmute. If a retry of Mute arrives after Unmute, the channel ends muted. Each request is safe to repeat; together they still race. Race conditions in UI covers ordering.

Same effect, not the same response

MDN’s example: the first DELETE “will likely return a 200, while successive ones will likely return a 404.” Treat “already gone” as success when you retry.

A counter needs something to count

You can’t make “add one” repeat-safe without knowing what the one is. Unread messages have IDs; a “new messages” badge built from delivery events doesn’t, until you give it them.

Your client decides what it retries

Go’s http.Transport retries after some network errors only for requests it considers idempotent: “GET, HEAD, OPTIONS, or TRACE; or if their Header map contains an "Idempotency-Key" or "X-Idempotency-Key" entry.” Your PUT is safe to repeat, but it won’t repeat it for you.

Logs and metrics still count every request

RFC 9110 says a server “is free to log each request separately.” Idempotence is about the intended effect; if a repeat bills or notifies someone, that’s part of the effect.

A toggle can hide inside a set

PUT { muted: !current } sends a state, but current may be stale. Send what the person chose, from the control they used, not a flip of what the page last knew.

09 / Make the call

What would you have to change tomorrow?

Give both versions a plausible change and follow the work it creates.

How a change affects a toggle and counter, and a set state with unread IDs
The changeToggle and counterSet state and ID set
A keyboard shortcut on local stateA flip is what you want.Reads the current value first.
Retry a save that timed outMight undo it.Send it again.
The server redelivers messagesBadge counts twice.Ignored by ID.
Mark read from an effectGuard against re-runs.Re-runs send the same position.
Show which messages are unreadOnly a number to show.The IDs are already there.

Describe the result when the operation crosses a network, runs from an effect, or can be triggered twice. A Mute button that talks to a server is the moment.

Keep a relative update when it runs once, locally, and the step is the point.

The question I’d leave beside the code is: if this runs a second time, what changes?

10 / Take the idea with you

Explain the unmuted channel without saying “idempotent.”

“The button said ‘flip it’, and the flip got sent twice. Now it says ‘muted is true’, which means the same thing no matter how many times it arrives. We did the same for unread: we store which messages are unread instead of adding one per delivery.” In a review, the words are idempotent, absolute update, and safe to retry.

Before moving on, jot down why the double-click unmuted the channel, why the badge said 2, and one request in your own code that flips or adds when it could set.

Connections to follow nextRelated lessons

Take the reactions into your editor. Make addReaction safe to retry, decide what removing a reaction should send, and write the test with a retry in it.

Back to Concepts & practices →