An error starts its life as something deeply uninteresting: a database that stops responding and a server that returns 500 with an HTML body nobody asked for. It ends its life as a sentence on a screen, something along the lines of "we couldn't save your changes, please try again". Between those two points there is a journey, and the quality of an application shows quite clearly in how many hands touch that error along the way.
In most codebases the journey doesn't exist. What exists is teleportation: the component that made the call receives the raw object from the HTTP client, inspects it right there, and decides on the spot what text to show. Multiplied by forty screens, that means forty different criteria for what counts as a recoverable error, forty ways of writing the same message, and forty chances to forget the case where there is no network.
This article is about the journey. Four layers, each with a responsibility the others don't have, and one rule that orders them: each layer translates the error into a vocabulary closer to the user and further from the transport. The interceptor speaks in HTTP codes, the mapping speaks in domain terms, the policy speaks in behavior, and the last layer speaks in pixels.
Since the pattern doesn't belong to any particular framework, every code block comes in TypeScript, Kotlin and Swift. It's worth looking at all three versions even if you only work in one, because the differences are informative: there is one layer that React solves with a class from the framework itself and that has to be built by hand in the other two.
1. The symptom: the catch copied forty times
This is the code that shows up when there is no system. It isn't a straw-man example, it's literally what gets written when the only available tool is try/catch.
1function SaveProfileButton({ profile }: { profile: Profile }) {
2 const [error, setError] = useState<string | null>(null)
3
4 async function handleSave() {
5 try {
6 const response = await fetch("/api/profile", {
7 method: "PUT",
8 body: JSON.stringify(profile),
9 })
10
11 if (!response.ok) {
12 if (response.status === 401) {
13 window.location.href = "/login"
14 return
15 }
16 if (response.status === 422) {
17 const body = await response.json()
18 setError(body.message ?? "Invalid data")
19 return
20 }
21 setError("Something went wrong")
22 return
23 }
24
25 toast.success("Profile saved")
26 } catch (e) {
27 // Network down? Malformed JSON? A bug of ours? There's no way to tell here.
28 setError("Something went wrong")
29 }
30 }
31}There are four problems and none of them is about style.
The first is that the view knows about HTTP codes. 401 and 422 are transport details, and they are written in a file whose job is to draw a button. The day the backend swaps 422 for 400, someone has to hunt that number down across the whole project.
The second is that the final catch is a junk drawer where completely unrelated things end up: the user being in a tunnel, the server returning HTML where JSON was expected, and a failure in our own code because profile wasn't what we thought it was. All three end up in the same message, and all three call for very different responses. In Kotlin and Swift the problem is even worse than it looks, because that generic catch also swallows the cancellation of the coroutine or the Task, and swallowing it breaks structured concurrency.
The third is that the decision to redirect to login lives in the edit-profile screen. That decision has to be repeated, identically, in every call in the application, and forgetting it once is enough to leave a user with an expired session staring at an eternal "something went wrong".
The fourth is the most expensive in the long run: there is nowhere to answer the question "what does the application do when the backend fails?". The answer is spread across forty files and it's different in every one of them.
The code to look for is usually a statusCode or a code() inside a UI file. If a screen compares HTTP numbers, the translation never happened and the view is doing the work of three layers.
2. Layer 1, the interceptor: normalize everything that can go wrong
The first layer is the only one with the right to know HTTP exists. Its job isn't to decide anything, it's to guarantee a single output shape. After this layer, the rest of the system cannot receive surprises.
And it matters to understand how many different surprises there are. A network request doesn't fail in one way, it fails in at least five:
- The server responds with an error code. There is a response, there is a
status, and sometimes there is a useful body. - The server responds with something other than what it said it would.
200 OKwith the HTML of a maintenance page, or truncated JSON. The decoder throws and thestatusis fine. - There is no response. The device is offline, DNS fails, the certificate doesn't validate. There is no
statusto look at. - The response arrives late. Nobody cancelled it, so the wait stays alive forever. This is the failure most often forgotten because it doesn't throw anything.
- Someone cancels it. The user navigates to another screen and the in-flight request is aborted. Technically it's an error, but it isn't a failure and it shouldn't reach the user.
A client that doesn't distinguish these five cases is doomed to lie in its message. This one does distinguish them, with no dependencies beyond each platform's HTTP client.
1export type HttpFailure =
2 | { kind: "http"; status: number; body: unknown }
3 | { kind: "malformed"; status: number; raw: string }
4 | { kind: "offline" }
5 | { kind: "timeout"; ms: number }
6 | { kind: "aborted" }
7
8export class HttpError extends Error {
9 constructor(readonly failure: HttpFailure, readonly requestId: string | null) {
10 super(`HTTP failure: ${failure.kind}`)
11 this.name = "HttpError"
12 }
13}
14
15const TIMEOUT_MS = 15_000
16
17export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
18 const timeout = new AbortController()
19 const timer = setTimeout(() => timeout.abort(), TIMEOUT_MS)
20
21 let response: Response
22 try {
23 response = await fetch(path, { ...init, signal: timeout.signal })
24 } catch (cause) {
25 clearTimeout(timer)
26
27 // The timeout aborts on its own, so it has to be told apart from an external cancellation.
28 if (timeout.signal.aborted) throw new HttpError({ kind: "timeout", ms: TIMEOUT_MS }, null)
29 if (init.signal?.aborted) throw new HttpError({ kind: "aborted" }, null)
30 throw new HttpError({ kind: "offline" }, null)
31 }
32 clearTimeout(timer)
33
34 const requestId = response.headers.get("x-request-id")
35 const raw = await response.text()
36
37 let parsed: unknown = null
38 if (raw.length > 0) {
39 try {
40 parsed = JSON.parse(raw)
41 } catch {
42 // A 200 with HTML inside is a failure, not a success. It stops here.
43 throw new HttpError({ kind: "malformed", status: response.status, raw }, requestId)
44 }
45 }
46
47 if (!response.ok) {
48 throw new HttpError({ kind: "http", status: response.status, body: parsed }, requestId)
49 }
50
51 return parsed as T
52}Notice what these functions do not do. They don't decide whether to retry, they don't display anything, they don't redirect, they don't log. The only thing they contribute is a very specific promise to the rest of the system: if this fails, it fails with an HttpError and its failure type is one from a closed, known set. That's the entire value of the layer, and it's enormous, because from here on the catch where you can't tell what came in disappears.
It's worth comparing how each platform treats cancellation, because it's the case most often done wrong. In TypeScript you have to tell the timeout's own abort from one coming from outside by hand, by checking which signal is aborted. In Kotlin cancellation is an exception that has to be rethrown as is, and there is a subtle trap: withTimeout throws a TimeoutCancellationException, which is a CancellationException, so the order of the catch blocks matters. In Swift it arrives as URLError.cancelled or as CancellationError depending on where it came from. Three different models for the same concept, and the interceptor is where they get flattened.
Having the timeout live in the interceptor rather than in every call is half the benefit. A request without a timeout doesn't produce an error, it produces an infinite spinner, and an infinite spinner shows up in no error metric. It's the hardest failure to detect in production precisely because it's silent.
3. Layer 2, from network error to domain error
This is where the important translation happens. The previous layer speaks in status codes and transport failure types. The application doesn't want to know about any of that: it wants to know whether the user has no permission, whether the data is invalid, whether the resource no longer exists, or whether this is something transient worth another attempt.
That list is the taxonomy, and it's best kept short. Five or six types are enough for almost any application, and the proof that a type is redundant is that its default behavior is identical to another's.
1export class AuthError extends Error {} // The session is invalid. Renew it or log out.
2export class ForbiddenError extends Error {} // The session is valid, the permission isn't. No retry.
3export class ValidationError extends Error { // The user can fix it.
4 constructor(readonly fields: Record<string, string>, message: string) {
5 super(message)
6 }
7}
8export class NotFoundError extends Error {} // It existed or never did. The view decides.
9export class TransientError extends Error {} // Our fault or the network's, and it can happen.
10export class UnknownError extends Error {} // We don't understand it. Someone has to investigate.And now the translator. It's a pure function, boring and trivial to test, which is exactly what you want from the piece that concentrates all the knowledge about the backend contract.
1export function toDomainError(error: unknown): Error {
2 if (!(error instanceof HttpError)) {
3 // An error that isn't an HttpError is a bug of ours, not a network failure.
4 return new UnknownError(error instanceof Error ? error.message : "unknown")
5 }
6
7 const { failure } = error
8
9 switch (failure.kind) {
10 case "offline":
11 case "timeout":
12 case "malformed":
13 return new TransientError("The connection failed")
14
15 case "aborted":
16 return error // Not a failure. It propagates as is so nobody displays it.
17
18 case "http": {
19 const message = extractMessage(failure.body)
20
21 if (failure.status === 401) return new AuthError(message)
22 if (failure.status === 403) return new ForbiddenError(message)
23 if (failure.status === 404) return new NotFoundError(message)
24 if (failure.status === 422) return new ValidationError(extractFields(failure.body), message)
25 if (failure.status === 429) return new TransientError(message)
26 if (failure.status >= 500) return new TransientError(message)
27
28 return new UnknownError(message)
29 }
30 }
31}The pattern is the same in all three languages, and that's no accident: it isn't a React technique or a TypeScript one, it's a boundary between transport and domain. Where there is a difference is in how much help the compiler gives. In Kotlin and Swift the closed types (sealed class and enum) make the when or the switch exhaustive: if tomorrow the interceptor adds a sixth failure type, the translator stops compiling and someone has to decide what to do with it. In TypeScript you get the same thing with a discriminated union, which is exactly why HttpFailure was declared that way above and not as a class with optional fields.
One detail that gets overlooked: the transient error groups together things with very different origins (a 500, a timeout, a 429, a corrupted response) because from the user's point of view they are the same thing: it didn't work and it will probably work in a moment. The taxonomy is organized by what the user should do, not by what technically happened. That distinction is what keeps the list short.
An error type is justified by the different behavior it triggers. If two types do exactly the same thing, they aren't two types, they're one type with two causes.
4. Layer 3, the policy: the default behavior
With the taxonomy in hand you can answer the question that used to be spread across forty files: what does the application do, by default, for each type of error. And you can answer it in a single place.
1type Policy = {
2 retries: number
3 notify: "toast" | "silent" | "screen"
4 onRaise?: () => void
5 rethrowToBoundary: boolean
6}
7
8export const POLICY: Record<string, Policy> = {
9 AuthError: { retries: 0, notify: "silent", onRaise: renewSessionOrLogout, rethrowToBoundary: false },
10 ForbiddenError: { retries: 0, notify: "screen", rethrowToBoundary: false },
11 ValidationError: { retries: 0, notify: "silent", rethrowToBoundary: false }, // The view renders it.
12 NotFoundError: { retries: 0, notify: "screen", rethrowToBoundary: false },
13 TransientError: { retries: 2, notify: "toast", rethrowToBoundary: false },
14 UnknownError: { retries: 0, notify: "silent", rethrowToBoundary: true }, // Let it break visibly.
15}Those six lines are executable documentation. A new developer reads them and knows how the entire product behaves on failure, and a change of criteria ("429s will retry three times with increasing backoff") happens once.
The least obvious and most important decision is the last row. An unknown error means the backend returned something we can't interpret or that we have a bug. The temptation is to show a friendly message and move on, but that turns an unknown failure into an invisible one, and an invisible failure never gets fixed. The right thing is to let it break in a controlled, visible way.
And the policy is applied in the data access layer, not in the UI:
1export async function call<T>(path: string, init?: RequestInit): Promise<T> {
2 let attempt = 0
3
4 while (true) {
5 try {
6 return await request<T>(path, init)
7 } catch (raw) {
8 const error = toDomainError(raw)
9 const policy = POLICY[error.constructor.name]
10
11 if (error instanceof HttpError) throw error // Cancellation: out without noise.
12
13 if (policy && attempt < policy.retries) {
14 attempt++
15 await wait(2 ** attempt * 250) // Exponential backoff: 500ms, 1s.
16 continue
17 }
18
19 policy?.onRaise?.()
20 if (policy?.notify === "toast") toast.error(userMessage(error))
21
22 throw error
23 }
24 }
25}Note that call always keeps throwing. The policy decides the side effects (retry, notify, renew the session), but it doesn't swallow the error: whoever called has the right to know there is no data. A policy layer that silently returns null is worse than having no layer at all, because it produces empty screens with no explanation.
Retrying is only safe if the operation is idempotent. A GET can be repeated three times with no consequences; a POST that creates a payment cannot. As soon as there are writes, automatic retry needs an idempotency key sent by the client, so the server recognizes the second attempt as the same one as the first and doesn't charge twice.
5. Layer 4, the render safety net
The three previous layers cover the errors that were expected. The fourth covers the ones that weren't: the null value someone asks a property of, the loop over something that wasn't a list, the unknown error the policy decided to let fly. Without this layer, any of them leaves the screen blank or takes the whole application down.
And this is where the three platforms stop resembling each other. React has a first-class mechanism for this, the ErrorBoundary. Compose and SwiftUI have nothing like it: an exception during composition or during a view's body can't be intercepted per subtree, so the safety net has to be built by hand, and it consists of never letting the error reach the render.
1type Props = { fallback: (error: Error, reset: () => void) => ReactNode; children: ReactNode }
2type State = { error: Error | null }
3
4export class ErrorBoundary extends Component<Props, State> {
5 state: State = { error: null }
6
7 static getDerivedStateFromError(error: Error): State {
8 return { error }
9 }
10
11 componentDidCatch(error: Error, info: ErrorInfo) {
12 reportToMonitoring(error, { componentStack: info.componentStack })
13 }
14
15 render() {
16 if (this.state.error) {
17 return this.props.fallback(this.state.error, () => this.setState({ error: null }))
18 }
19 return this.props.children
20 }
21}What you have to be clear about with React's ErrorBoundary is its limit: it only catches errors thrown during render, in lifecycle methods and in the constructors of its children. It catches nothing from an event handler, nor from a setTimeout, nor from a rejected promise. That's the reason layers 1 to 3 exist: the async error doesn't reach the boundary on its own, it has to be reflected in the component's state first so that the next render throws it. In Compose and SwiftUI that "reflect it in the state" isn't a trick, it's the only option, and that's why a UiState with an explicit failure branch ends up being the dominant pattern in both.
The other common mistake is having just one, at the root. With that setup, a failure loading the "recommended products" list takes down the entire application, shopping cart included. The right granularity is three levels, and this applies equally in any UI tree:
1<ErrorBoundary fallback={fullPageCrash}> {/* Root: the last resort. */}
2 <Layout>
3 <ErrorBoundary fallback={routeCrash} key={pathname}> {/* Route: resets on navigation. */}
4 <Dashboard>
5 <ErrorBoundary fallback={widgetCrash}> {/* Widget: local degradation. */}
6 <Recommendations />
7 </ErrorBoundary>
8 </Dashboard>
9 </ErrorBoundary>
10 </Layout>
11</ErrorBoundary>The key={pathname} on the route boundary is a small detail with a big effect: without it, the boundary stays in an error state forever and the user who navigates to another section keeps seeing the red screen from the previous one. When the key changes, React unmounts the subtree and the error state goes with it. In Compose the equivalent is tying the state to the current route, with a key on the NavHost, so navigating creates a new one instead of reusing the one that is in a failed state.
And with the four layers in place, the code from the beginning ends up like this:
1function SaveProfileButton({ profile }: { profile: Profile }) {
2 const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
3
4 async function handleSave() {
5 try {
6 await call("/api/profile", { method: "PUT", body: JSON.stringify(profile) })
7 toast.success("Profile saved")
8 } catch (error) {
9 // Only the case that genuinely belongs to this view is handled.
10 if (error instanceof ValidationError) setFieldErrors(error.fields)
11 }
12 }
13}No HTTP codes, no login redirect, no generic message, no retries. All of that still happens, but it happens in the layer it belongs to, once, for the entire application. And notice Swift's catch DomainError.validation: the catch itself already filters by type, so the remaining errors pass straight through with no need for an if to discard them.
6. The error that does belong to the view
Up to here it might look like the goal is for no view to ever touch an error. That isn't it. The goal is for views to only touch errors whose response depends on the specific view, and there are two families that meet that condition.
The first is validation. A 422 saying the email field is already in use only makes sense next to the email field of that form. No generic layer knows where that field is or how to highlight it, so the validation error is marked as silent in the policy and travels intact all the way to whoever knows what to do with it.
The second is the 404 with product meaning. A "not found" error when opening an order's detail should lead to a "this order doesn't exist" screen with a link to the list. The same error when checking whether a user has a saved draft isn't an error at all: it means "there is no draft", and the correct response is an empty form. Same error type, two opposite behaviors, and no central table can decide between them because the information needed is the context of the call.
From that comes the rule for telling whether an error should go up to the policy or stay in the view: if the correct response changes depending on which screen made the call, it belongs to the view; if it's always the same, it belongs to the policy. A 401 is always the same across the whole application, so it goes up. A 404 depends, so it's let through.
7. What gets logged and the thread that ties both sides together
An error system that doesn't inform the team is only being polite to the user. The fourth responsibility, cutting across the four layers, is observability, and here the criterion is the opposite of what people usually do: log less, not more.
A validation error isn't an incident, it's the system working (someone typed an email wrong). A session-expired error isn't one either, sessions expire. An isolated transient error is normal network noise. If all of that goes into the monitoring tool, in two weeks nobody looks at the alerts and the unknown error that did matter is buried among thousands of irrelevant events.
1const SEVERITY: Record<string, "ignore" | "breadcrumb" | "error"> = {
2 ValidationError: "ignore", // Normal usage, not an incident.
3 AuthError: "breadcrumb", // Context for the next real error.
4 ForbiddenError: "breadcrumb",
5 NotFoundError: "breadcrumb",
6 TransientError: "breadcrumb",
7 UnknownError: "error", // The only thing that wakes somebody up.
8}The breadcrumb entries don't alert, but they're recorded and they show up attached to the next real error. When an unknown error arrives, the report doesn't just say "it failed"; it says "it failed after three transient errors and an expired session in the last twenty seconds", which is already half the investigation solved.
The other piece is the x-request-id that the interceptor stored back in layer 1 and that has travelled with the error all the way here. That identifier is generated by the backend (or generated by the client and honored by the backend), and it appears in the logs on both sides. Without it, investigating a failure reported by a user means searching by approximate time across millions of lines. With it, it's an exact query that reconstructs the complete request, and that's why it's worth dragging it from the first header to the last report.
Put the requestId on the error screen itself, in small type. It costs five minutes and it turns "it doesn't work for me" into actionable data as soon as the user takes a screenshot.
8. The complete journey
It's worth walking the whole path once, because the division of responsibilities makes more sense end to end than in isolated layers.
The database goes down. The server returns 503. Layer 1 sees the response isn't successful, extracts the x-request-id, and throws an HttpError with an HTTP-type failure and status: 503. End of its job: it doesn't know what 503 means and it doesn't care. Layer 2 translates that 503 into a transient error, and with that the number disappears from the system forever. Layer 3 consults the policy, sees two retries, waits 500 milliseconds and retries; the database is still down, it waits a second, retries again, and when the third attempt fails it leaves a breadcrumb, fires a notification with the human message and throws the error upwards. Layer 4 never gets to step in, because the notification already informed the user and the screen decided to show its empty state.
Four translations, each one in its place, and zero lines of error-handling code in the screen where the user was working. The message they see wasn't written by whoever drew the button: it was written by the layer that knew what had happened.
That's the whole trick, and it's identical across the three platforms because it doesn't depend on any of them. The error doesn't travel alone, and at every stop someone strips a technical detail from it and adds a little intent.


