00:00

Fallback Pattern in Microservices

Last updated:

The fallback pattern gives a service a planned alternative answer when a call to another service fails. Instead of passing an error up to the user, the caller returns something useful but less complete: a cached value, a sensible default, or a partial page. The goal is graceful degradation: the system keeps working with reduced features instead of failing completely.

This tutorial explains how the fallback pattern works, the main types of fallback, and when a fallback is the wrong answer. Three runnable Java 21 examples show a fallback chain, choosing fallbacks by failure type, and combining a fallback with a circuit breaker. Interview questions follow at the end.

1. What Is the Fallback Pattern?

A fallback is the code that runs when the normal path, usually a remote call, does not succeed. Think of a product page in an online shop. It shows the product details, the price, reviews and "customers also bought" recommendations, and each part may come from a different service. If the Recommendation Service is down, the page can show a fixed list of bestsellers instead. The customer may not even notice.

The fallback pattern is one of the resilience patterns. It usually works together with the timeout pattern, which decides when to stop waiting, the retry pattern, which tries again, and the circuit breaker pattern, which stops calling a failing service. The fallback decides what to return once those have given up.

2. Why Microservices Need Fallbacks

In a microservices system, failures are partial. At any moment, one of fifty services may be restarting, overloaded or unreachable, while the other forty-nine are fine. Without fallbacks, every page that touches the broken service fails, and a small problem in a minor feature becomes a full outage.

Fallbacks separate critical features from optional ones. Taking an order is critical; personalised recommendations are optional. Optional features should never be able to take down critical ones. With fallbacks, the shop keeps selling while the recommendation team fixes their service.

3. How the Fallback Pattern Works

How a fallback decision is madeThe product page asks for a price. If the circuit breaker is closed, the call goes to the Pricing Service. A 200 OK response returns the live price. If the Pricing Service answers with a business error such as 400 or 404, the error is passed on and no fallback is used. If the call fails technically, with a timeout, a 503 or a refused connection, or if the circuit breaker is open and skips the call, the fallback chain starts. First the cached price is used if it is fresh enough, and the response shows its age. On a cache miss, a default response says the price is unavailable. Both fallback responses are marked as degraded. Product page needs a price Circuit breaker open? skip the call Pricing Service live price Response source: live Business error (400, 404) passed on, no fallback 1. Cached price if fresh enough 2. Default "price unavailable" Response degraded: true closed 200 OK answered "no" timeout, 503, connection refused open: fail fast miss hit: use the cached price, show its age
Figure 1: technical failures go down the fallback chain; real business answers are passed on unchanged.
  1. Try the primary call, with a timeout. If a circuit breaker is open, skip the call entirely.
  2. Classify the failure. A timeout, a 503 or a refused connection means the service could not answer, so a fallback may help. A 400 Bad Request, a 404 Not Found or "card declined" is a real answer, and must be passed on.
  3. Run the fallback chain in order of quality: for example, a fresh cached value first, then a safe default.
  4. Mark the response as degraded, so the UI can adjust and monitoring can count it.

4. Types of Fallback

4.1 Cached or Stale Data

Return the last good value, such as yesterday's exchange rate or a product description from five minutes ago. Decide how old is still acceptable. A product description can be a day old; a stock count for checkout cannot.

4.2 Default or Static Value

Return a safe, fixed answer: bestsellers instead of personal recommendations, standard delivery time instead of a calculated one, or "price unavailable" instead of a price. A default should never pretend to be real data.

4.3 Alternative Source

Call a backup: a second payment provider, a read replica of a database, or a simpler version of the service. This keeps full quality but adds another dependency that can also fail.

4.4 Partial Response

Leave out the failed part. The product page loads without the reviews section, and shows "Reviews are temporarily unavailable". This works well with the aggregator pattern, where one response combines several services.

4.5 Deferred Write

For a write, accept the request and finish it later: save the order as "payment pending" and retry the charge from a queue. This is honest, because the user is told the payment is pending, not that it succeeded.

4.6 No Fallback: Fail Fast

Sometimes the right fallback is a fast, clear error. If the Payment Service is down and you cannot defer the charge, say "Payment is temporarily unavailable, please try again in a few minutes". A quick, honest error is better than a slow timeout or a fake success.

5. Fallback Pattern in Java: Examples

These programs use only the JDK on Java 21, so the logic is visible. In a Spring Boot service, Resilience4j provides the same behaviour with annotations (section 6).

5.1 A Fallback Chain With Honest Results

The price comes live if possible, then from a cache if it is at most 30 minutes old, then as "unavailable". Each answer records its source:

FallbackChain.java
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class FallbackChain {

    // Every answer says where it came from, so the page can show it honestly
    record PriceAnswer(String sku, Optional<BigDecimal> price, String source, boolean degraded) {}

    record CachedPrice(BigDecimal price, Instant savedAt) {}

    static final Instant NOW = Instant.parse("2026-10-28T10:00:00Z");      // fixed clock for repeatable output
    static final Map<String, CachedPrice> cache = new HashMap<>();
    static boolean pricingUp = true;

    static BigDecimal callPricingService(String sku) {
        if (!pricingUp) throw new IllegalStateException("pricing-service timed out after 800 ms");
        return new BigDecimal("1499.00");
    }

    static PriceAnswer getPrice(String sku) {
        try {
            BigDecimal live = callPricingService(sku);
            cache.put(sku, new CachedPrice(live, NOW));                      // keep the last good value
            return new PriceAnswer(sku, Optional.of(live), "live", false);
        } catch (IllegalStateException e) {
            CachedPrice cached = cache.get(sku);
            if (cached != null && Duration.between(cached.savedAt(), NOW).toMinutes() <= 30) {
                long age = Duration.between(cached.savedAt(), NOW).toMinutes();
                return new PriceAnswer(sku, Optional.of(cached.price()), "cache, " + age + " min old", true);
            }
            return new PriceAnswer(sku, Optional.empty(), "default: price unavailable", true);   // never invent a price
        }
    }

    public static void main(String[] args) {
        System.out.println(getPrice("KTL-17"));

        pricingUp = false;
        cache.put("KTL-17", new CachedPrice(new BigDecimal("1499.00"), NOW.minus(Duration.ofMinutes(12))));
        cache.put("MUG-04", new CachedPrice(new BigDecimal("199.00"), NOW.minus(Duration.ofHours(5))));
        System.out.println(getPrice("KTL-17"));
        System.out.println(getPrice("MUG-04"));     // cached value too old to trust
        System.out.println(getPrice("TEA-500"));    // never cached

        PriceAnswer answer = getPrice("TEA-500");
        System.out.println("Show Buy button? " + answer.price().isPresent());
    }
}
Output
PriceAnswer[sku=KTL-17, price=Optional[1499.00], source=live, degraded=false]
PriceAnswer[sku=KTL-17, price=Optional[1499.00], source=cache, 12 min old, degraded=true]
PriceAnswer[sku=MUG-04, price=Optional.empty, source=default: price unavailable, degraded=true]
PriceAnswer[sku=TEA-500, price=Optional.empty, source=default: price unavailable, degraded=true]
Show Buy button? false

The five-hour-old price is rejected, and no price is invented. Because the answer says it is degraded, the page can hide the Buy button rather than sell at a wrong price.

5.2 Choosing a Fallback by Failure Type

This is the mistake many tutorials make in their own examples: they catch every exception and return a fallback. That turns "card declined" into something the customer never sees:

FallbackByFailureType.java
import java.util.ArrayList;
import java.util.List;

public class FallbackByFailureType {

    // Technical failure: the other service could not answer. A fallback may help.
    static class ServiceUnavailableException extends RuntimeException {
        private static final long serialVersionUID = 1L;
        ServiceUnavailableException(String message) { super(message); }
    }

    // Business answer: the other service DID answer, with "no". A fallback must not hide it.
    static class PaymentDeclinedException extends RuntimeException {
        private static final long serialVersionUID = 1L;
        PaymentDeclinedException(String message) { super(message); }
    }

    static final List<String> retryQueue = new ArrayList<>();

    static String chargeCard(String orderId, String scenario) {
        return switch (scenario) {
            case "ok" -> "PAID";
            case "down" -> throw new ServiceUnavailableException("payment-service: 503");
            case "declined" -> throw new PaymentDeclinedException("card declined: insufficient funds");
            default -> throw new IllegalArgumentException(scenario);
        };
    }

    static String placeOrder(String orderId, String scenario) {
        try {
            return orderId + " -> " + chargeCard(orderId, scenario);
        } catch (ServiceUnavailableException e) {
            retryQueue.add(orderId);                         // a write fallback: accept now, charge later
            return orderId + " -> PAYMENT_PENDING (" + e.getMessage() + ", queued for retry)";
        }
        // PaymentDeclinedException is not caught: the customer must see the real answer
    }

    public static void main(String[] args) {
        for (String[] call : new String[][] {{"ORD-1", "ok"}, {"ORD-2", "down"}, {"ORD-3", "declined"}}) {
            try {
                System.out.println(placeOrder(call[0], call[1]));
            } catch (PaymentDeclinedException e) {
                System.out.println(call[0] + " -> error shown to customer: " + e.getMessage());
            }
        }
        System.out.println("Waiting for payment retry: " + retryQueue);
    }
}
Output
ORD-1 -> PAID
ORD-2 -> PAYMENT_PENDING (payment-service: 503, queued for retry)
ORD-3 -> error shown to customer: card declined: insufficient funds
Waiting for payment retry: [ORD-2]

The technical failure gets a deferred-write fallback with an honest "pending" status. The business failure is not a failure of the system at all, so it reaches the customer unchanged.

5.3 Fallback With a Circuit Breaker, and Watching the Fallback Rate

Without a circuit breaker, every request waits for the full timeout before the fallback runs. With one, the fallback is returned immediately once the breaker opens:

BreakerWithFallback.java
import java.util.List;

public class BreakerWithFallback {

    enum State { CLOSED, OPEN }

    static State state = State.CLOSED;
    static int consecutiveFailures = 0, primaryCalls = 0, fallbacks = 0;
    static long waitedMs = 0;
    static final int FAILURE_THRESHOLD = 3, TIMEOUT_MS = 800;

    // Simulated recommendation service: down for this whole run
    static List<String> callRecommendations() {
        primaryCalls++;
        waitedMs += TIMEOUT_MS;                             // each failed call costs a full timeout
        throw new IllegalStateException("timeout");
    }

    static List<String> fallback() {
        fallbacks++;
        return List.of("Bestsellers: Steel Kettle, Tea Set");   // static, safe and cheap
    }

    static List<String> recommendations() {
        if (state == State.OPEN) return fallback();         // fail fast: no call, no waiting
        try {
            List<String> result = callRecommendations();
            consecutiveFailures = 0;
            return result;
        } catch (IllegalStateException e) {
            if (++consecutiveFailures >= FAILURE_THRESHOLD) state = State.OPEN;
            return fallback();
        }
    }

    public static void main(String[] args) {
        for (int request = 1; request <= 10; request++) {
            List<String> shown = recommendations();
            if (request <= 4 || request == 10) {
                System.out.println("Request " + request + " (" + state + "): " + shown);
            }
        }
        System.out.println("With the breaker:    " + primaryCalls + " calls to the failing service, " + waitedMs + " ms spent waiting");
        System.out.println("Without the breaker: 10 calls to the failing service, " + 10 * TIMEOUT_MS + " ms spent waiting");

        double fallbackRate = fallbacks * 100.0 / 10;
        System.out.printf("Fallback rate %.0f%% -> %s%n", fallbackRate,
                fallbackRate > 20 ? "ALERT: users see degraded pages" : "ok");
    }
}
Output
Request 1 (CLOSED): [Bestsellers: Steel Kettle, Tea Set]
Request 2 (CLOSED): [Bestsellers: Steel Kettle, Tea Set]
Request 3 (OPEN): [Bestsellers: Steel Kettle, Tea Set]
Request 4 (OPEN): [Bestsellers: Steel Kettle, Tea Set]
Request 10 (OPEN): [Bestsellers: Steel Kettle, Tea Set]
With the breaker:    3 calls to the failing service, 2400 ms spent waiting
Without the breaker: 10 calls to the failing service, 8000 ms spent waiting
Fallback rate 100% -> ALERT: users see degraded pages

Users got bestsellers on every request, so nothing looked broken, and that is the danger. A good fallback can hide an outage for hours. Count how often fallbacks are used, and alert when the rate is high. A real breaker also moves to a half-open state after a wait, letting a few trial calls through to check whether the service has recovered.

6. Fallbacks in Spring Boot

With Resilience4j, you name a fallback method in the annotation, for example @CircuitBreaker(name = "pricing", fallbackMethod = "cachedPrice"). The fallback method must be in the same class, have the same return type and the same parameters, plus one extra exception parameter at the end. You can write several fallback methods with different exception types; Resilience4j picks the one whose exception type is closest to the thrown one.

Because the annotations work through Spring AOP proxies, they do nothing when a method calls another annotated method in the same class. The call must come through the Spring bean.

Other options: Spring Cloud OpenFeign clients can declare a fallback or fallbackFactory class when circuit breaker support is enabled, and Spring Cloud Gateway's CircuitBreaker filter can forward to a fallbackUri when a route fails.

7. Comparing Fallback Types

Fallback typeGood forMain risk
Cached or stale dataDescriptions, settings, exchange ratesActing on data that is too old
Default valueRecommendations, delivery estimatesLooking like real data
Alternative sourcePayments, critical readsThe backup fails too, or behaves differently
Partial responsePages built from many servicesUI must handle missing sections
Deferred writeOrders, notifications, emailsNeeds a reliable queue and idempotent retry
Fail fastOperations that cannot be faked or delayedUsers see an error, but quickly and clearly

8. Common Mistakes and Pitfalls

  • Falling back on business errors. Catching all exceptions hides "not found", "invalid input" and "declined", as section 5.2 shows.
  • Fake success for writes. Returning "order confirmed" when payment did not happen creates angry customers and manual cleanup.
  • Stale data for critical decisions. Using a cached stock count to accept an order can oversell.
  • A fallback that calls another remote service without its own timeout. When both fail, the fallback makes things slower, not better.
  • The fallback stampede. When the primary fails, all traffic suddenly hits the fallback. If the fallback is a database query that normally serves 1% of requests, it can collapse under 100%. Make fallbacks cheap, or protect them with a bulkhead.
  • Untested fallbacks. Fallback code runs only during incidents, so it is the least tested code in the system. Test it on purpose, for example by stopping a dependency in a test environment.
  • Silent fallbacks. Without metrics, nobody notices that a feature has been degraded for a week.

9. Best Practices

  • Decide per feature whether it is critical or optional, and what a degraded version looks like, together with the product owner.
  • Fall back only on technical failures: timeouts, 5xx errors, refused connections and open circuit breakers.
  • Keep fallbacks simple, local and fast: in-memory data, a cache, or a static value.
  • Be honest: mark degraded responses, show the age of cached data, and never fake a successful write.
  • Combine with timeouts and circuit breakers, so the fallback runs quickly.
  • Measure and alert on fallback rates per dependency.
  • Test fallbacks regularly with failure-injection tests.

10. Summary

  • The fallback pattern returns a planned alternative when a remote call fails, so the system degrades gracefully.
  • Common fallbacks are cached data, defaults, alternative sources, partial responses, deferred writes and a fast, clear error.
  • Fall back on technical failures only; pass business errors such as "declined" to the user unchanged.
  • Use circuit breakers so fallbacks are immediate, and keep fallbacks cheap to avoid a stampede.
  • A good fallback pattern is honest, measured and tested, because a silent fallback can hide an outage.