Guide

Drupal redirect loop after content edit — detecting accidental cycles before users hit the redirect chain

A content editor renamed a node URL twenty minutes ago. Now users hitting /services/onboarding see Chrome's ERRTOOMANYREDIRECTS and Firefox's "The page isn't redirecting properly.

1. Problem

A content editor renamed a node URL twenty minutes ago. Now users hitting /services/onboarding see Chrome's ERR_TOO_MANY_REDIRECTS and Firefox's "The page isn't redirecting properly." The page loads in admin (logged-in users bypass the redirect cache), so the editor confirms "it's fine on my end" and closes the ticket. The drupal redirect loop after content update is invisible from inside /admin.

curl -sIL https://example.com/services/onboarding returns a wall of 301s — /services/onboarding/services/customer-onboarding/onboarding/services/onboarding and back around. The chain bounces until curl's default 50-hop limit kicks in. Search engines hit it first: Googlebot drops the page from the index within a crawl cycle, Search Console flags "redirect error," and the page falls out of search results before anyone in marketing notices.

Every change to the redirect table fires a drupal_redirect_change_total signal with action (add/update/delete) and entity_type drilldowns. A loop is almost always a burst of two or three of these in the same 30-second window, often immediately following a drupal_content_created_total event when an editor moved a node alias. This is the "drupal redirect module created cycle" pattern, and it's catchable before users hit the redirect chain.

2. Impact

The visible cost is SEO. A page that returns ERR_TOO_MANY_REDIRECTS for 48 hours typically loses 60–90% of its organic traffic to that URL — Google de-indexes the loop and only re-indexes the destination after the next crawl completes (3–14 days). For a SaaS marketing site, a looping /pricing or /demo page is a measurable hit to MQLs that quarter.

The quieter cost is conversion. Drupal Commerce checkout flows with redirects between /cart, /checkout, and /checkout/payment are fragile — a misconfigured rule that sends /checkout/payment back to /checkout produces a loop authenticated users also see (no cache saves them). The signature is "checkouts started but never completed." Funnel analytics show a cliff at one step, but the report doesn't flag it as a redirect issue, just as abandonment.

Editorial workflows compound the damage. The redirect module auto-creates a row on every alias change. Three editors moving content across a taxonomy reorganization can produce dozens of redirects in an hour, and the cycle that breaks production was created by someone who has no idea their rename was the trigger. By the time the page is reported broken, drupal_redirect_change_total has stacked up unread alongside drupal_content_created_total and drupal_menu_change_total.

3. Why It’s Hard to Spot

Drupal does not detect redirect loops by default. The redirect module's loop detection is opt-in — the redirect_loop_detect validator is off in a stock install. A loop sits in the redirect table as three perfectly valid rows; nothing in the admin UI flags them as a cycle. The form at /admin/config/search/redirect shows them as a flat list, sorted by date.

Editors don't see loops because of two compounding cache layers. Drupal's page_cache stores the first response for an anonymous URL, but the editor's authenticated session bypasses the redirect entirely. Behind that, Cloudflare or Varnish caches the redirect chain — sometimes with the loop baked into a Cache-Control: public, max-age=3600 response — so the broken URL keeps serving from edge nodes after the loop is fixed.

Uptime monitors miss this for the worst possible reason: a 301 is a successful HTTP response. Pingdom, UptimeRobot, and StatusCake all treat 301 as up. They follow up to n redirects (default ~10) before giving up, but most monitors are configured to alert on 5xx only. A page that bounces forever between 301s never trips them.

watchdog (the dblog module) doesn't log redirect creations. The only place the truth lives is the redirect table itself and — if you're emitting it — drupal_redirect_change_total. WordPress has no equivalent metric: WP plugins like Redirection log changes, but there's no first-class signal for redirect-table churn the way there is for wp.cron or wp.state_change. This pattern is Drupal-specific.

4. Cause

The Drupal redirect module subscribes to pathauto.alias_storage_helper.preSave events — when a node alias changes, the module automatically inserts a row in redirect mapping the old alias to the new one. This is the convenience feature that becomes the footgun.

The Logystera Drupal agent's RedirectChangeListener subscribes to the redirect entity insert/update/delete hooks and emits drupal_redirect_change_total with two label drilldowns: action (one of add, update, delete) and entity_type (typically redirect for redirect-module rows, path_alias for direct alias edits). Every row added or modified increments the counter — independent of whether the change came from the admin UI, an alias rename, a drush php:eval script, or a feature module on entity:save.

A loop is created the moment three rows exist in redirect such that A→B→C→A. The module evaluates each rule independently — there's no graph traversal at insert time. WordPress redirect plugins don't expose an equivalent metric (and their loops are rarer because WP doesn't auto-create redirects on slug change), so the detection burden falls entirely on the Drupal side. drupal_redirect_change_total is the only place this churn surfaces.

5. Solution

5.1 Diagnose (logs first)

The diagnosis is mechanical: confirm the loop exists in HTTP, find the participating rows in the redirect table, then time-correlate with the most recent content edit.

1. Confirm the loop in HTTP — the symptom that brought you here.

# -L follows redirects, -I does HEAD, --max-redirs 20 stops the chain
curl -sIL --max-redirs 20 https://example.com/services/onboarding 2>&1 \
    | grep -iE "^HTTP|^location" | head -n 30

A healthy URL prints one or two HTTP/2 301 lines and then HTTP/2 200. A loop prints the same Location: value reappearing — that's your cycle. Save the loop URLs; you'll need them in step 2.

2. Find the rows in the redirect table — the smoking gun.

drush -r /var/www/drupal/web sql:query \
    "SELECT rid, redirect_source__path, redirect_redirect__uri, status_code, created
     FROM redirect
     WHERE redirect_source__path IN ('services/onboarding', 'services/customer-onboarding', 'onboarding')
        OR redirect_redirect__uri LIKE '%onboarding%'
     ORDER BY created DESC;"

The output is the loop, written down in three rows. The redirect_redirect__uri column uses Drupal's internal: scheme (e.g., internal:/services/customer-onboarding). Look for a chain where row 1's destination is row 2's source, row 2's destination is row 3's source, and row 3's destination is row 1's source. Each one of these rows produced a drupal_redirect_change_total{action="add"} event when it was created.

3. List recent redirect churn to find the burst.

drush -r /var/www/drupal/web sql:query \
    "SELECT rid, redirect_source__path, redirect_redirect__uri,
            FROM_UNIXTIME(created) AS created_at
     FROM redirect
     WHERE created > UNIX_TIMESTAMP(NOW() - INTERVAL 24 HOUR)
     ORDER BY created DESC
     LIMIT 50;"

This lists every redirect created in the last 24 hours — the same set of events drupal_redirect_change_total{action="add"} recorded as a counter. Three or more rows in the same one-minute window, all touching overlapping path components, is the loop signature.

4. Time-correlate with content edits.

Loops cluster around an editor's session: drupal_content_created_total fires when the editor saves a node, drupal_menu_change_total if they reorganize the menu, drupal_redirect_change_total when pathauto auto-creates the forward. Find the editor.

# Who edited content in the last 30 minutes? (node revision log)
drush -r /var/www/drupal/web sql:query \
    "SELECT n.nid, n.title, nr.revision_uid, u.name,
            FROM_UNIXTIME(nr.revision_timestamp) AS edited_at
     FROM node_revision nr
     JOIN node_field_data n ON n.nid = nr.nid
     JOIN users_field_data u ON u.uid = nr.revision_uid
     WHERE nr.revision_timestamp > UNIX_TIMESTAMP(NOW() - INTERVAL 30 MINUTE)
     ORDER BY nr.revision_timestamp DESC;"

If the most recent drupal_redirect_change_total{action="add"} events line up with drupal_content_created_total events from a single editor, the story writes itself: editor renamed a node, pathauto updated the alias, redirect auto-created a forward that closed a cycle with two pre-existing rows. That correlation turns "the page is broken" into "the loop was created at 14:07 UTC, immediately after editor m.weber saved node 1842."

5.2 Root Causes

Each cause maps to a signal pattern and a Drupal-side fix. Prioritized by frequency.

  • Auto-created redirect on alias change closes a pre-existing cycle — the redirect module's pathauto integration created a new row that, combined with old rows from a previous reorg, formed A→B→C→A. Produces a burst of drupal_redirect_change_total{action="add", entity_type="redirect"} immediately after drupal_content_created_total. Most common cause; the cycle hides for weeks until someone touches the third node.
  • Manual redirect rule contradicts an existing rule — an editor or SEO manager added a rule via /admin/config/search/redirect/add pointing at a URL that itself has a redirect. Produces a single drupal_redirect_change_total{action="add"} with no preceding drupal_content_created_total. Check dblog for Redirect added by user X.
  • Feature/migration module re-imported old redirects — a drush cim of exported redirect config re-creates redirects from a snapshot, including ones intentionally deleted to break a previous loop. Produces a sudden spike in drupal_redirect_change_total{action="add"} — dozens in a few seconds — correlated with drupal_config_save_total.
  • Trailing-slash mismatch/foo redirects to /foo/, which the Symfony router redirects back via RedirectableUrlMatcher. The redirect table only has one row, but the loop exists because core also redirects. Produces a single drupal_redirect_change_total{action="add"} followed by user-visible loops without further table churn.
  • Querystring passthrough disabled/foo?utm_source=x is treated as different from /foo, but a rule strips the querystring. Loops fire only for tracked traffic. Surfaces as user reports from campaign URLs.
  • Multilingual prefix collision — a rule on the en site contradicts a rule on de through a shared base path. Produces drupal_redirect_change_total{action="add"} on one language and visible loops only on the other.

5.3 Fix

Match the fix to what the redirect table told you, not to a guess.

Cause A — Auto-created redirect closed a cycle: delete the looping rule. The auto-created row is almost always the wrong one to keep — the editor's new alias is what they intended, and the old alias should redirect to it directly, not via a chain.

# Identify the looping rid from the SELECT above, then:
drush -r /var/www/drupal/web sql:query "DELETE FROM redirect WHERE rid = 4271;"
drush -r /var/www/drupal/web cr
# Verify the loop is gone:
curl -sIL --max-redirs 5 https://example.com/services/onboarding | grep -iE "^HTTP|^location"

Cause B — Manual rule contradicts existing rule: delete the newer rule from /admin/config/search/redirect. If the older rule is the wrong one, delete that instead — the redirect table doesn't enforce uniqueness on source path strictly enough.

Cause C — Feature/migration re-imported old redirects: revert the offending config import or run a targeted cleanup on the import window. Don't clear the entire redirect table — you'll lose intentional SEO redirects.

Cause D — Trailing-slash mismatch: standardize. Pick one (with or without trailing slash) and update redirect.settings:default_status_code and any nginx/apache rewrite rules to match.

Cause E — Querystring passthrough: flip redirect.settings:passthrough_querystring to true and re-test.

drush -r /var/www/drupal/web cset redirect.settings passthrough_querystring 1 -y
drush -r /var/www/drupal/web cr

Canonical hardening — enable loop detection. Once the fire is out, turn on the redirect module's loop detection so the next loop is rejected at insert time. The module's RedirectChecker::canRedirect() walks the chain and refuses to add a row that would close a cycle. With this enabled, dangerous rows are blocked before they hit the table.

drush -r /var/www/drupal/web cset redirect.settings route_normalizer_enabled 1 -y
# And install Redirect 8.x-1.8+ which validates loops on form submission.

5.4 Verify

Two things must hold simultaneously: drupal_redirect_change_total returns to its normal baseline rate, and the loop URL serves a single 301 to a 200 final page.

# Should return one or two 301s, then 200, with no repeating Location header:
curl -sIL --max-redirs 5 https://example.com/services/onboarding | grep -iE "^HTTP|^location"

# Purge edge caches so the loop response stops serving from CDN:
drush -r /var/www/drupal/web cache:rebuild
# Plus a Cloudflare/Fastly purge for the affected URLs.

The baseline matters. A healthy editorial Drupal site emits roughly 2–10 drupal_redirect_change_total events per editor-day — alias renames, intentional SEO redirects, occasional cleanups. Bursts of 5+ events in a 60-second window are the loop-creation pattern. After the fix, expect the rate to settle to that baseline within an hour. If drupal_redirect_change_total{action="add"} is still firing 3+ events per minute thirty minutes after you deleted the looping rule, something is creating new redirects in a loop — usually a cron job or a stuck batch operation. Check drush queue:list and drupal_config_save_total for the secondary cause.

The acid test: curl -sIL returns exactly one 301 followed by 200, repeated three times with sleep 5 between, with no looping Location: header. If that's stable for 15 minutes under normal traffic and drupal_redirect_change_total is at baseline, the issue is resolved.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as drupal_redirect_change_total.

Everything you just did manually — curl -L to confirm the loop, drush sql:query against the redirect table to find the participating rows, time-correlate with the editor's node_revision timestamps — Logystera does automatically. The Drupal agent's RedirectChangeListener subscribes to the redirect entity hooks and emits drupal_redirect_change_total to the gateway the instant a row is inserted, with action and entity_type drilldowns. A burst of three add events on entity_type=redirect within 30 seconds is a distinct shape from the steady drip of intentional add events from entity_type=path_alias during normal editorial work.

!Logystera dashboard — drupal_redirect_change_total over time drupal_redirect_change_total rate by action, last 24h — burst of three add events at 14:07 UTC, immediately after a content editor's node alias rename.

The rule that fires is id 588 — Drupal redirect-table churn burst, severity warning, threshold 3 add events on action=add within 60 seconds. That threshold catches editor-induced cycles without alerting on the steady single-event rate of normal editorial redirects. A second rule, id 589 — Drupal redirect cycle suspected, severity critical, runs a graph check on the table when 588 fires — if it finds a closed cycle in the new rows, it escalates immediately.

!Logystera alert — Drupal redirect cycle suspected Critical alert fires within 60s of the third drupal_redirect_change_total{action="add"} event closing a cycle, with the participating source paths.

The alert payload includes the timestamp, the three redirect_source__path values that form the cycle, the editor's revision_uid from the most recent node save, and the affected entity name. That's enough to land in /admin/config/search/redirect and delete the right row from a phone, before search engines re-crawl. WordPress has no equivalent — WP plugins log changes but don't expose a counter to alert on, which is why redirect cycles are a Drupal-detectable failure that WP-only monitoring can't replicate.

The fix is simple once you know the problem. The hard part is knowing it happened at all. Logystera turns this kind of failure from a marketing-team-screams-on-Friday emergency into a 60-second notification with the three URLs that prove it.

7. Related Silent Failures

  • drupal_content_created_total bursts — paired signal, fires immediately before redirect-loop creation when an editor renames multiple nodes in sequence. Useful for attributing the loop to a specific editorial session.
  • drupal_menu_change_total — menu reorganizations often trigger redirect chains because menu links and node aliases share path space. A menu rebuild that touches 50 links can cascade into dozens of drupal_redirect_change_total{action="update"} events.
  • drupal_config_save_totaldrush cim of exported redirect config can re-introduce previously deleted loops. Look for spikes correlated with deploy windows.
  • 404 storm after redirect deletion — fixing a loop by deleting all three rows leaves the original old URLs returning 404 to existing inbound links. Surfaces as a drupal_request_errors_total 404 spike on the deleted source paths.
  • drupal_path_alias_change_total — direct edits to path_alias (without going through pathauto) bypass the redirect module's auto-create entirely, leaving stale URLs that 404 instead of looping. Different failure mode, related signal family.

See what's actually happening in your Drupal system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.