skip to content

A go_router app shows its error screen with 'redirect loop detected' or 'too many redirects' after a sign-in change; how do you diagnose and fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. one redirect history per navigation
  2. a location seen twice is a loop
  3. redirectLimit defaults to five hops
  4. two guards that disagree
  5. debugLogDiagnostics prints every hop

basics

~20 s

go_router records each location a navigation's redirects produce: a repeated location throws 'redirect loop detected', and more hops than redirectLimit (default 5) throws 'too many redirects'. Turn on debugLogDiagnostics, find the guards that disagree, and give the chain a fixed point.

solid answer

~40 s

Within one navigation, go_router keeps a redirect history shared by the top-level `redirect` and every `GoRoute.redirect`. If a redirect produces a location already in that history it throws a `GoException` reading 'redirect loop detected', listing the chain; if the chain grows past `redirectLimit`, default 5, it throws 'too many redirects'. Either ends on the error screen, or in `onException` if one is set. I turn on `debugLogDiagnostics: true` to see each hop, then look for the two usual causes: two guards reading different state (the top-level redirect trusts `session.signedIn` while `/accounts` checks a token that has not loaded), or a login redirect that keeps wrapping `?from=` because it does not exempt `/login`. The fix is one source of truth and an exit on every branch, not a larger `redirectLimit`.

code

dart · 30 lines
dart
import 'package:go_router/go_router.dart';

// Loops: '/' -> '/login' (top level), then '/login' -> '/' (route level).
// go_router throws: redirect loop detected /login => / => /login
GoRouter buggy(bool Function() signedIn, bool Function() hasToken) => GoRouter(
      debugLogDiagnostics: true,
      redirect: (context, state) => signedIn() ? null : '/login',
      routes: <RouteBase>[
        GoRoute(path: '/', builder: (context, state) => const Placeholder()),
        GoRoute(
          path: '/login',
          builder: (context, state) => const Placeholder(),
          // Reads a different piece of state than the top-level rule.
          redirect: (context, state) => hasToken() ? null : '/',
        ),
      ],
    );

// Fixed: one source of truth, the login page exempt, a fixed point on both sides.
GoRouter fixed(bool Function() signedIn) => GoRouter(
      redirect: (context, state) {
        final bool atLogin = state.matchedLocation == '/login';
        if (!signedIn()) return atLogin ? null : '/login';
        return atLogin ? '/' : null;
      },
      routes: <RouteBase>[
        GoRoute(path: '/', builder: (context, state) => const Placeholder()),
        GoRoute(path: '/login', builder: (context, state) => const Placeholder()),
      ],
    );

go deeper

for a junior

Recall the two messages: a repeated location means a redirect loop, and more hops than redirectLimit, default 5, means too many redirects.

for a middle

Explain the shared redirect history, why returning null or the current location is not a hop, and how the error reaches errorBuilder or onException.

for a senior

Demonstrate reading the logged chain, mapping each hop to a guard and the state it reads, and fixing it with one source of truth rather than a larger limit.

for a principal

Argue how to structure access rules across teams so guards cannot contradict each other: one owner for session policy, route redirects only for local rules.

## How go_router detects the problem A **navigation** in go_router is one parse of a location: a `go`, a `push`, a deep link, a browser back, or a `refreshListenable` notification. During that parse, redirects may move the target several times. go_router records each resulting match list in a **redirect history** shared by the top-level `redirect` and every route-level `GoRoute.redirect`: - If a redirect produces a location **already in the history**, go_router throws a `GoException` with the message `redirect loop detected`, followed by the chain joined with `=>`. - If the history is already at `redirectLimit` (a `GoRouter` constructor argument, **default 5**) when another hop arrives, it throws `too many redirects`, again with the chain. - Returning `null`, or the location currently being evaluated, is **not** a hop, so a guard that says "stay here" never loops by itself. The exception becomes an error match list. It is shown by `errorBuilder` or `errorPageBuilder`, by go_router's default error screen if neither is set, or passed to `onException`, which supersedes both. The constructor asserts that **at most one** of those three is provided. ## Reading the symptom | Message | Shape of the chain | Usual cause | |---|---|---| | `redirect loop detected /login => /accounts => /login` | two locations alternating | two guards disagree about the session | | `too many redirects /login?from=... => /login?from=/login?from=...` | a location that grows each hop | a redirect that does not exempt its own target | | either, only after sign-in or sign-out | appears on a `refreshListenable` notification | state read mid-update, or two sources of truth | ## Diagnosing step by step 1. Set `debugLogDiagnostics: true` on the `GoRouter`; the package logs `redirecting to ...` for each hop, and the exception message lists the whole chain. 2. Write the chain down and, for each hop, find which callback produced it: the top-level `redirect`, a `GoRoute.redirect` on the target or one of its parents, or an `onEnter` `Block.then` that calls `router.go`. 3. For each callback, note **what state it reads**. Loops almost always come from two callbacks reading two different things: `session.signedIn` in one, a cached token or a user profile in another. 4. Check the login target itself: a signed-out redirect must return `null` when `state.matchedLocation` is already `/login`, or it will wrap `from` again on every hop. 5. Check timing: if a guard reads state that flips during sign-in (the flag is set before the token is stored), a notification between the two updates can see an inconsistent pair. ## Tracing the example Take the buggy router in the code example, with a signed-out user who has no stored token, opening `/`: 1. The top-level redirect sees signed out and returns `/login`; the history is now `[/login]`. 2. The top-level redirect re-runs on `/login`, returns `/login` again, which equals the current location, so it is not a hop. 3. Route-level redirects run: `/login`'s own redirect sees no token and returns `/`; the history becomes `[/login, /]`. 4. The top-level redirect re-runs on `/` and returns `/login`, which is already in the history. 5. go_router throws `redirect loop detected /login => / => /login`, and the error screen replaces the app. The two callbacks each look reasonable alone. The loop exists only because they read different state: one asks "signed in?", the other "token present?", and for this user the answers disagree. ## Fixing it properly - Give authentication **one source of truth**, a single object that exposes a coherent state such as signed out, signing in, or signed in, and have every guard read only that. - Put the session rule in **one place**, usually the top-level redirect, and keep route-level redirects for rules about their own subtree. - Make every branch reach a **fixed point**: the login page is exempt from the signed-out rule, and the signed-in rule leaves `/login` for a location that nothing redirects again. - Notify the `refreshListenable` **after** the state is fully updated, not between two partial writes. - Do not "fix" it by raising `redirectLimit`. A loop is detected regardless of the limit, and a growing chain only fails later. Raise the limit only for a genuine, finite chain longer than five hops. ## Guarding against regressions Cover the rule with widget tests that build the router, sign in and out, and assert the final location; a loop shows up as the error screen, so the test fails with a readable chain instead of a user report.

  • With go_router, should you raise redirectLimit to make 'too many redirects' go away?
    Almost never. A true loop is detected from the repeated location whatever the limit, and a chain that grows each hop just fails a little later. Raise it only for a genuine, finite chain longer than the default five hops, after the chain has been read.
  • With go_router, where does a redirect GoException surface when onException is set?
    `onException` is called with the context, a `GoRouterState` whose `error` holds the exception, and the router; go_router then keeps the current configuration instead of building an error page. The constructor asserts that only one of `onException`, `errorPageBuilder` and `errorBuilder` is given.
  • With go_router, how can a sign-in flow loop only when refreshListenable fires?
    The notification re-parses the current location and re-runs every redirect. If the session notifies between two partial updates, say the flag is set but the token is not yet stored, one guard sees signed in and another sees signed out, and they bounce the location between them.

saying these in an interview costs you the question

  • Raises redirectLimit until the error screen disappears.
  • Assumes returning the current location from redirect creates a loop.
  • Believes go_router silently stops a loop and shows the last page.
  • Duplicates the auth check in top-level and route redirects with different flags.
  • Passes both errorBuilder and onException, expecting both to run.