Crawling

Reduce Google’s crawling of our website

Google ignores Crawl-delay. How to slow down its crawling: block fake Googlebots by IP and return 503 or 429 with Retry-After on Apache, LiteSpeed and nginx.

Reduce Google’s crawling of our website
Author
Carlos Sánchez
Topics
Crawling
Published
2026-11-02
Reviewed
2026-11-02

It's true that we will rarely ask ourselves how to make Google crawl our website less often. But when analyzing the logs and the uptime of our website, we may find a correlation: it goes down at the peaks when Google is crawling it.

Or, to get trickier, we may be in the middle of changes in production where we don't want Google to crawl us too much, so that it doesn't keep low-quality content in its cache, but the change isn't drastic enough to put the website in 5XX (or to serve 5XX only to Google).

The problem with the Crawl-delay directive

The Crawl-delay directive in the robots.txt file doesn't work with Google.

Other search engines, such as Bing, do respect it to space out their requests.

It's also true that Bing makes more of an effort: in Bing Webmaster Tools you even have a crawl control where you can tell Bing the best hours to crawl you.

Crawl control in Bing Webmaster Tools

However, not all bots are that considerate when it comes to letting us organize the crawling of our website. In fact, sometimes we need to know how to block access to user agents, even with measures much more powerful than robots.txt. Googlebot, which is also a bot we generally want on our website, ignores the Crawl-delay directive completely, and Search Console doesn't give us an option to change the crawl rate either.

So we could give up and decide it's better not to touch Google and let it crawl whatever it wants. But in some projects there can be a peak in Google's crawling at times when the server can't handle that much load.

I've seen Crawl-delay applied directly to Googlebot, and it can sometimes lead to rules being applied in unexpected ways, because Googlebot ignores this directive and that has its risks, as we can see in this example:

Problem with ignored robots.txt directives

My recommendation would always be to have the server ready for peak times, but we may want to limit Google because it crawls us excessively.

Blocking by IP range

The most likely case when a website goes down because of excessive crawling by Google's bots is that they aren't really Google's bots. And although it's relatively complex to implement, if we don't use a CDN that does this for us, we can block on our website every Google user agent that doesn't come from this IP range.

Google's crawler IP range lists

Although it isn't what I'd recommend, this list is very important for websites that are, for example, banned in the US and need to block it, but still need to appear on Google.

For what concerns us here, blocking crawling for anything that doesn't fall within the IP range often works quite well.

Airbnb, for example, does this: if we pretend to be Google, it blocks us.

Airbnb block
But when we use a Google service, with Google's user agent and from Google's IP range, we can see that it lets us through without much trouble.

For anyone wondering, neither this implementation nor the hyper-personalization it does for the user hurts its rankings; if anything, the opposite.

Now, what happens if we make these changes and still find that our legitimate Google is really the one crawling us excessively? Well, we have an ace up our sleeve.

503 or 429 status codes and the Retry-After header

The technically correct way to make Googlebot stop is to return a 5XX or 429 HTTP status code.

This can come with the Retry-After header. That way you protect your infrastructure immediately and give Google a standardized instruction on when it's safe to come back. Otherwise, you leave it up to Google when to try again: it will reduce crawling considerably and take a while to make changes in the index, but with Retry-After you're in control.

On October 6, Google updated its guide to reducing the crawl rate and explicitly added the use of this header (as defined in RFC 9110).

The guide shows how the delay can be given in seconds:

HTTP/1.1 503 Service Unavailable
Retry-After: 120

Or as an absolute date and time in UTC:

HTTP/1.1 503 Service Unavailable
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT

How to send Retry-After on Apache, LiteSpeed and nginx

Two decisions: which responses it goes on (503 for maintenance, 429 for rate limiting) and with what value (seconds, or an HTTP date such as Retry-After: Wed, 07 Oct 2026 18:00:00 GMT). And one rule: the header goes with the status, not with the path. If you add it to every response in a directory, the 200s will get it too, and there it means nothing.

Apache. Two modules you already have do the job: mod_rewrite sets the status and mod_headers the header, tied to the status with expr= (Apache 2.4.10 or later). It works the same in .htaccess and in the VirtualHost.

Whole site · .htaccess in the root, or the VirtualHost
# Maintenance for the whole site: 503 and a one-hour Retry-After.
# Your IP still gets in, and the maintenance page (and its folder) isn't blocked.
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.10$
  RewriteCond %{REQUEST_URI} !^/maintenance
  RewriteRule ^ - [R=503,L]
</IfModule>
ErrorDocument 503 /maintenance.html

# The header only when the response is a 503 (Apache 2.4.10 or later).
# "always": without it, Apache only adds headers to 2xx responses.
<IfModule mod_headers.c>
  Header always set Retry-After "3600" "expr=%{REQUEST_STATUS} == 503"
</IfModule>
Just one directory · .htaccess inside /shop/
# Only /shop/ returns 503; the rest of the site stays up.
# In the VirtualHost it would be the same inside <Location /shop/> … </Location>
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.10$
  RewriteRule ^ - [R=503,L]
</IfModule>
ErrorDocument 503 /maintenance.html

<IfModule mod_headers.c>
  Header always set Retry-After "7200" "expr=%{REQUEST_STATUS} == 503"
</IfModule>
429 for a specific bot · Apache doesn't limit requests on its own
# Real rate limiting is a job for a WAF or mod_security. But if you want to stop
# a bot with a 429 (which says "come back later") instead of a 403 (which says "go away"):
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_USER_AGENT} (AhrefsBot|SemrushBot) [NC]
  RewriteRule ^ - [R=429,L]
</IfModule>

<IfModule mod_headers.c>
  Header always set Retry-After "600" "expr=%{REQUEST_STATUS} == 429"
</IfModule>

Check that the header comes out only with the status it belongs to, and from an IP that isn't on your allowlist (your phone without Wi-Fi will do): curl -sI https://your-domain.com/ | grep -iE "^(HTTP|retry-after)"

Per-IP limit: 429 and Retry-After only for whoever crawls too much

A 503 for maintenance stops everyone equally. But excessive crawling almost always has a name: a scraper, an SEO tool in a hurry, an AI bot or someone pretending to be Googlebot. For that you don't need to touch anyone else: I count the requests from each IP and return 429 Too Many Requests only to the one that goes over its quota, with a Retry-After that tells it when it can come back.

The quota depends on who it is, and the user agent isn't enough to know that: anyone can write "Googlebot". For Googlebot and Bingbot I verify the IP, as in the IP range block above, and I give them more room, because Google doesn't slow down just that IP: when it sees enough 429s, it reduces crawling across the whole hostname, and it recovers on its own once they stop. Whoever claims to be Googlebot from an IP that isn't Google's counts as just another bot.

The Retry-After comes from the same calculation in all three: how long the client takes to get its whole burst back, burst ÷ rate. After that time it can come back at full speed; before then, only at its own rate. Google accepts it with a 429 just as it does with a 503. And robots.txt stays out of the limit: if Google gets a 429 when requesting it, it stops crawling the whole site for the first 12 hours while it keeps retrying.

Apache and LiteSpeed. Neither of them has a per-IP limit that returns a 429 (LiteSpeed's slows clients down without telling them), so PHP does it, before WordPress gets to work. It needs nothing installed, because it keeps the counters in the temp folder, and it also works behind an nginx you can't touch. Whatever the page cache or the CDN serves doesn't go through PHP: it neither counts nor costs anything.

limit-per-ip.php · in wp-content/mu-plugins/ (create it if it doesn't exist): WordPress loads it on its own, without activating it
<?php
/**
 * Plugin Name: Per-IP limit (429 + Retry-After)
 * Description: 429 Too Many Requests for the IP that crawls too much, with a quota and a Retry-After based on who it is.
 */// In WordPress, in wp-content/mu-plugins/. On any other PHP site, with auto_prepend_file.

if ( PHP_SAPI === 'cli' || defined( 'DOING_CRON' ) ) {
    return;
}

// Per client: requests per second and burst. The same bucket as nginx's limit_req.
const RL_SOURCES = [
    'person'    => [ 5, 60 ],     // a browser never gets there: static files don't go through PHP
    'searchbot' => [ 2, 120 ],    // Googlebot or Bingbot with a verified IP
    'bot'       => [ 0.5, 60 ],   // everything else, including whoever claims to be Googlebot and isn't
];
// Retry-After = the time it takes to refill the burst: 12 s, 60 s and 120 s.

function rl_dir(): string {
    $dir = sys_get_temp_dir() . '/per-ip-limit-' . md5( __DIR__ );    // one folder per site
    if ( ! is_dir( $dir ) ) {
        @mkdir( $dir, 0700, true );
    }
    if ( mt_rand( 1, 1000 ) === 1 ) {    // now and then, remove whatever hasn't been touched for a day
        foreach ( glob( $dir . '/*' ) ?: [] as $old ) {
            if ( @filemtime( $old ) < time() - 86400 ) {
                @unlink( $old );
            }
        }
    }
    return $dir;
}

/* The client is the IP; for IPv6, its /64 network, which is what each home or server gets. */function rl_client( string $ip ): string {
    $bin = @inet_pton( $ip );
    return ( false !== $bin && strlen( $bin ) === 16 ) ? bin2hex( substr( $bin, 0, 8 ) ) . '/64' : $ip;
}

/* Is it really Googlebot or Bingbot? Reverse DNS to its domain and forward back to the same IP,
   as Google explains. The answer is cached for a day per IP. */function rl_verified_searchbot( string $ip, string $ua ): bool {
    if ( ! preg_match( '/googlebot|googleother|google-inspectiontool|bingbot/i', $ua ) ) {
        return false;
    }
    $cached = rl_dir() . '/dns-' . md5( $ip );
    if ( is_file( $cached ) && filemtime( $cached ) > time() - 86400 ) {
        return file_get_contents( $cached ) === '1';
    }
    $host = gethostbyaddr( $ip );
    $ok   = is_string( $host ) && preg_match( '/\.(googlebot\.com|search\.msn\.com)$/i', $host );
    if ( $ok ) {
        $ips = strpos( $ip, ':' ) !== false
            ? array_column( @dns_get_record( $host, DNS_AAAA ) ?: [], 'ipv6' )
            : ( gethostbynamel( $host ) ?: [] );
        $ok = in_array( inet_pton( $ip ), array_map( 'inet_pton', $ips ), true );
    }
    @file_put_contents( $cached, $ok ? '1' : '0' );
    return $ok;
}

function rl_source( string $ip, string $ua ): string {
    if ( rl_verified_searchbot( $ip, $ua ) ) {
        return 'searchbot';
    }
    $bots = '/bot|crawl|spider|slurp|scrapy|python|curl|wget|httpclient|java\/|go-http|headless/i';
    return ( '' === $ua || preg_match( $bots, $ua ) ) ? 'bot' : 'person';
}

function rl_apply(): void {
    // Logged-in WordPress users don't count (without setting the current user yet).
    if ( function_exists( 'wp_validate_auth_cookie' ) && wp_validate_auth_cookie( '', 'logged_in' ) ) {
        return;
    }
    // Never robots.txt: with a 429 there, Google stops crawling the whole site for up to 12 hours.
    if ( strpos( $_SERVER['REQUEST_URI'] ?? '', '/robots.txt' ) === 0 ) {
        return;
    }
    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    if ( '' === $ip ) {
        return;
    }
    $source           = rl_source( $ip, $_SERVER['HTTP_USER_AGENT'] ?? '' );
    [ $rate, $burst ] = RL_SOURCES[ $source ];

    // The client's bucket: each request spends a token; $rate tokens come back per second, up to $burst.
    $f = @fopen( rl_dir() . '/' . md5( $source . rl_client( $ip ) ), 'c+' );
    if ( ! $f ) {
        return;                        // without a temp folder, nobody is limited
    }
    flock( $f, LOCK_EX );
    $now     = microtime( true );
    $bucket  = explode( ' ', (string) stream_get_contents( $f ) );
    $before  = isset( $bucket[1] ) ? (float) $bucket[1] : $now;
    $tokens  = isset( $bucket[1] ) ? (float) $bucket[0] : $burst;
    $tokens  = min( $burst, $tokens + ( $now - $before ) * $rate );
    $allowed = $tokens >= 1;
    if ( $allowed ) {
        $tokens--;
    }
    ftruncate( $f, 0 );
    rewind( $f );
    fwrite( $f, $tokens . ' ' . $now );
    flock( $f, LOCK_UN );
    fclose( $f );
    if ( $allowed ) {
        return;
    }

    // A Retry-After that makes sense: the seconds it takes to get the whole burst back.
    $wait = (int) ceil( ( $burst - $tokens ) / $rate );
    http_response_code( 429 );
    header( 'Retry-After: ' . $wait );
    header( 'Cache-Control: no-store' );
    header( 'Content-Type: text/plain; charset=utf-8' );
    echo 'Too many requests. Try again in ' . $wait . ' seconds.';
    exit;
}

// In WordPress, once the session can be checked; in any other PHP, right away.
if ( function_exists( 'add_action' ) ) {
    add_action( 'plugins_loaded', 'rl_apply', 0 );
} else {
    rl_apply();
}
Outside WordPress · .htaccess in the root, with LiteSpeed Enterprise or with Apache and mod_php
# PHP runs the limiter before any other file on the site (absolute path).
<IfModule LiteSpeed>
  php_value auto_prepend_file /home/user/site/limit-per-ip.php
</IfModule>
<IfModule mod_php.c>
  php_value auto_prepend_file /home/user/site/limit-per-ip.php
</IfModule>
With PHP-FPM · .user.ini in the root, because there PHP doesn't read .htaccess
; PHP rereads this file every five minutes.
auto_prepend_file = /home/user/site/limit-per-ip.php

On OpenLiteSpeed neither works: the same php_value line goes in the WebAdmin, under Virtual Hosts → General → php.ini Override.

To check it · a burst with a bot user agent; each request carries a different parameter to get past the cache
for i in $(seq 100); do
  echo "$i $(curl -s -o /dev/null -A 'test-bot' -w '%{http_code} %header{retry-after}' "https://your-domain.com/?limit=$i")"
done | grep -m1 ' 429 '
# 62 429 120   ← the first 429 response, with its Retry-After (curl 7.84 or later)
# It doesn't affect you while browsing: the bot's bucket and your browser's are separate.

Two warnings before you switch it on. If the site sits behind Cloudflare, Bunny or any other proxy, the IP the server sees is the proxy's, and with it everyone would share the same quota: first make the server see the visitor's IP (set_real_ip_from and real_ip_header in nginx, mod_remoteip in Apache, "Use Client IP in Header" in LiteSpeed). And if Googlebot gets the 429 every day, it's no longer a peak: raise its quota or fix the load, because Google warns that errors lasting more than one or two days can end up removing URLs from the index.

If you like this article, you would help me a lot by sharing my content:
Training
Interested in Advanced SEO Training?

I currently offer advanced SEO training in Spanish. Would you like me to create an English version? Let me know!

Tell me you're interested
You might be interested in other articles:
SEO Articles