Graceful degradation is a design principle in software and system engineering that ensures a system continues functioning – albeit with reduced performance or features – when one or more of its components fail or encounter problems.
Rather than completely breaking down, the system “degrades gracefully” by maintaining core functionality and providing a minimally viable user experience. Which aspect is degraded depends on the kind of system/software.
For example, a mapping service might stop returning additional details about a city area because of a network slowdown but still will let the user navigate the areas of the map that have already been downloaded; a website might remain navigable and readable even if certain scripts, images, or advanced features don’t load, like webmail that will still let you edit your emails even if you are in airplane mode.
The concept of “graceful degradation” contrasts with “fail-fast” approaches, where a system immediately halts operations when it encounters a failure. Graceful degradation emphasizes resilience and user-centric design by ensuring critical services remain accessible during partial disruptions.
As usual, the code for this article is available on GitHub. We will use tags to follow our path along the “degradation” of the functionalities.
To support our explanation, we will use a simple application (written in Deno/Fresh but the language/framework is irrelevant in this article) that will invoke a remote API to get a fresh joke for the user.
The interface is pretty simple and the code can be found on the repository (at this tag in particular).
The islands\Joke.tsx
file is a preact component responsible for displaying a random joke in a web interface. It uses the useState and useEffect Hooks
to manage the joke’s state and fetch data when the component mounts. The joke is fetched from the /api/joke
endpoint, and users can retrieve a new one by clicking a button. The component renders the joke along with a button that triggers fetching a new joke dynamically when clicked.
The routes\api\joke.ts
file defines an API endpoint that returns a random joke. It fetches a joke from an external API (for this example, we use a service but any other similar service is fine) and extracts the setup and punchline. The response is then formatted as a single string (setup + punchline
) and returned as a JSON response to the client.
The application doesn’t do much, but from an architectural point of view, it is comprised of two tiers: the frontend and the backend with the API. Our frontend is simple and cannot fail, but the backend, our “joke” API, can fail: it relies on an external service that is out of our control.
Let’s look at the current version of the API:
import { FreshContext } from "$fresh/server.ts"; export const handler = async (_req: Request, _ctx: FreshContext): Promise<Response> => { const res = await fetch( "https://official-joke-api.appspot.com/random_joke", ); const newJoke = await res.json(); const body = JSON.stringify(newJoke.setup + " " + newJoke.punchline); return new Response(body); };
The first kind of failure we will implement is aiming to randomly get a timeout on the external API call. Let’s modify the code:
import { FreshContext } from "$fresh/server.ts"; export const handler = async ( _req: Request, _ctx: FreshContext, ): Promise<Response> => { // Simulate a timeout by setting a timeout promise const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), 200) ); // Fetch the joke from the external API const fetchPromise = fetch( "https://official-joke-api.appspot.com/random_joke", ); // Race the fetch promise against the timeout const res = await Promise.race([fetchPromise, timeoutPromise]); if (res instanceof Response) { const newJoke = await res.json(); const body = JSON.stringify(newJoke.setup + " " + newJoke.punchline); return new Response(body); } else { return new Response("Failed to fetch joke", { status: 500 }); } };
In this new version, we add a timeoutPromise
that will “race
” with our external API call: if the external API answers in less than 200ms
(i.e. wins the race), we get a new joke, otherwise, we get null
as a result. This is disruptive – our frontend relies on the response from the API as a JSON object, and it gets a message (“Failed to fetch joke”) and a 500 HTTP error. In the browser, it will produce these effects:
The joke is not refreshed and you get an error message in the console because the message you get from the API is not a formatted JSON. To mitigate the random timeouts we injected in our API code, we can provide a safety net: when the fetch fails, we return a standard joke formatted as the frontend expects:
... // Race the fetch promise against the timeout const res = await Promise.race([fetchPromise, timeoutPromise]); if (res === null) { // If the timeout wins, return a fallback response const fallbackJoke = { setup: "[cached] Why did the developer go broke?", punchline: "Because they used up all their cache!", }; const body = JSON.stringify( fallbackJoke.setup + " " + fallbackJoke.punchline, ); return new Response(body); } ...
To mitigate the effects of the failure we just created, we check the call has returned null; in such case, it comes in handy to have a fallbackJoke
that will be returned in the same format expected by the frontend. This simple mechanism has augmented the resilience of our API to a particular type of failure: the unpredictable timeout of the external API.
In the timeout example, the mechanism we deployed to mitigate still relies on the fact that the server with the external API is reachable. If you unplug the network cable from your PC (or activate airplane mode), you will see that the frontend will fail in a new way:
The reason is that the backend is not able to reach the external API server and thus returns an error to the backend (check the logs from Deno for more information). To mitigate this situation, we must modify the backend to be aware of the failure of the external API and then handle it by serving a fallback joke:
... // If the fetch completes in time, proceed as usual if (res instanceof Response) { const newJoke = await res.json(); const body = JSON.stringify(newJoke.setup + " " + newJoke.punchline); return new Response(body); } else { throw new Error("Failed to fetch joke"); } } catch (_error) { // Handle any other errors (e.g., network issues) const errorJoke = { setup: "[cached] Why did the API call fail?", punchline: "Because it couldn't handle the request!", }; const body = JSON.stringify(errorJoke.setup + " " + errorJoke.punchline); return new Response(body, { status: 500 }); } };
The mitigation relies on the fact that instead of returning a generic “Failed to fetch joke” message, we wrap the whole interaction with the external API server in a try/catch block. This block will let us handle the network failure by serving a local joke instead of an expressive error message. This is the final solution to the possible errors you can get on the backend, and it increases the system’s resilience.
In the previous section, we increased the resilience to failures but we also want to keep a user-centric approach as a part of the graceful degradation. At the moment, the user is not aware if the joke they get is fresh or not. To increase this knowledge, we will extend the JSON returned from the backend to keep track of the freshness of the joke. When the external API fails, the JSON that is returned to the frontend will state that the joke is not fresh (fresh
is false
):
const errorJoke = { setup: "Why did the API call fail?", punchline: "Because it couldn't handle the request!", fresh: false };
Otherwise, when the external API succeeds, we return a JSON object with the fresh
field set to true
:
if (res instanceof Response) { const newJoke = await res.json(); newJoke.fresh = true; const body = JSON.stringify(newJoke); return new Response(body); }
Now that the frontend receives the freshness of every joke, we just need to show it to the user:
When the external API call fails, a message is shown in red, so the user knows what they are getting.
In this article, we explored the concept of graceful degradation, highlighting two mechanisms for mitigating system failures. We explored two principles for implementing graceful degradation: building resilient components to withstand failures and adopting a user-centric approach so users are aware of any limited functionalities of the system in case of failures.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowcursor
propertyLearn about built-in CSS cursors, creating custom cursors with CSS, using multiple cursors, and adding animations with CSS and JavaScript.
Build a React Native Turbo Module for Android to access device info like model, IP, uptime, and battery status using native mobile APIs.
Learn how to measure round-trip time (RTT) using cURL, a helpful tool used to transfer data from or to a server.
React.memo prevents unnecessary re-renders and improves performance in React applications. Discover when to use it, when to avoid it, and how it compares to useMemo and useCallback.
One Reply to "A guide to graceful degradation in web development"
Greetings, Thank You