DEV Community

Cover image for A guide to graceful degradation in web development
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

A guide to graceful degradation in web development

Written by Rosario De Chiara✏️

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.

Implementing graceful degradation in a demo application

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](https://blog.logrocket.com/react-hooks-cheat-sheet-solutions-common-problems/) 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.

Failures and mitigations

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

First failure: Handling API timeouts gracefully

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

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:

Random Joke Generator Frontend Sample

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

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.

Second failure: Handling network errors gracefully

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:

Random Joke Generator With A Frontend Failure

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

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.

Mitigation for the frontend

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

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

Now that the frontend receives the freshness of every joke, we just need to show it to the user:

Random Joke Generator Frontend Sample

When the external API call fails, a message is shown in red, so the user knows what they are getting.

Conclusion

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.


Get set up with LogRocket's modern error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now.

Top comments (0)