# How often do VPN IP addresses change? Less than 1% a day

A month of VPN, Tor and residential proxy data compared day by day: how long to cache a VPN check, how long to wait for one, and when to fail closed.

A VPN check on a request path makes three choices: how long to wait for an answer, what to do when none comes, and how long to keep the one that does. The maintainer of a Laravel package hit the first in production. Its drivers gave two free VPN APIs 0.8 seconds, production logged `cURL error 28: Connection timed out after 803 milliseconds`, and each timeout went to the error tracker as a bug ([the issue](https://github.com/jeffersongoncalves/laravel-visitor-fingerprint/issues/2)). The fix raised the limit to 2 seconds and stopped reporting timeouts.

The third choice comes down to how fast VPN addresses change, and the published answers disagree. [ipgeolocation.io](https://ipgeolocation.io/real-time-proxy-and-vpn-detection.html) says VPN and proxy IPs "change fast, sometimes within minutes". The [Conticop/VPNs](https://github.com/Conticop/VPNs) list on GitHub says its IPs "change infrequently" and is "usually updated every 2 months". Neither gives a number, so we measured it.

## How long a VPN answer stays true

We took the IPv4 addresses in our VPN data on each day from September 22 to October 22, 2026, and checked which were still flagged the next day and a week later. Each figure is the median of 31 nights, or of the 25 weeks that ended by October 23. Tor is the same measurement on our Tor data.

| Flagged on one day | Still flagged the next day | A week later |
|---|---:|---:|
| VPN, provider named | 99.7% | 98.2% |
| Every VPN address | 99.4% | 94.2% |
| Tor | 99.6% | 94.8% |

On 25 of the 31 nights, more than 99% of the VPN addresses were still flagged, and 0.6% of the next day's were new. The other six nights lost between 1% and 18%, and single weeks ranged from 75% to 96%: those are the stretches when a cached answer stays wrong longest. A verdict can also change category. Most of October 23's 10.7% were enterprise gateways, Zscaler's and Cisco's among them, which left `is_vpn` for `is_relay` that day. A Zscaler address that answered `is_vpn` on October 22 answered `is_relay` on October 24, provider `zscaler`.

These are daily snapshots, so they can't show what changes within a day.

## Residential proxies

A residential proxy is someone's home connection, relaying traffic while a device in the house runs a proxy network's software, and that traffic moves daily. Of the IPv4 addresses we saw carrying it on one day, about 5.1 million a day, 51% to 53% were seen again the next day, over the six nights to October 23. Six days later, 39.6% were.

`is_resproxy` doesn't move with them. An address we saw once, on July 19, still answered `is_resproxy: true` on October 24, with `last_seen` `2026-07-19` and `hits` 1. So what ages is the sighting inside the answer, not the answer you cached: `last_seen` and `hits` say how long ago and how often. Our [post on how long residential proxy IPs last](https://vpndetection.io/blog/how-long-do-residential-proxy-ips-last) turns them into a score that fades.

## A cache time and a timeout per route

Every framework middleware we publish, Express to Rails, keeps an answer for an hour, gives a lookup 2.5 seconds, and lets the request through when the lookup fails. By the table, the hour costs almost nothing. A day's cache leaves about 0.6% of VPN answers out of date on a median night, and a week's 5.8%.

So a page can keep an answer for a day. A payout is rare enough to afford a lookup each time, and it should refuse when there's no answer. Here's an Express app that does both, with pages giving the API one second:

```bash
npm install express vpndetection-express
```

```js
import express from 'express';
import { vpndetection } from 'vpndetection-express';

const app = express();
app.set('trust proxy', 'loopback');

const DAY = 24 * 60 * 60 * 1000;

// Pages and signups: keep an answer a day, and let the visitor in
// if the lookup fails or takes longer than a second.
const screen = vpndetection({
    apiKey: process.env.VPNDETECTION_API_KEY,
    timeoutMs: 1000,
    cache: { max: 100000, ttlMs: DAY },
});

// Payouts: look up every time, and refuse when there's no answer.
const payout = vpndetection({
    apiKey: process.env.VPNDETECTION_API_KEY,
    cache: false,
    failClosed: true,
    blockCondition: [{ isVpn: true }, { isTor: true }, { isResproxy: true }],
});

app.get('/', screen, (req, res) => {
    const { result, error } = req.vpndetection;
    res.json({ isVpn: result?.isVpn, error: error?.kind });
});

app.post('/payout', payout, (req, res) => {
    res.json({ paid: true });
});

app.listen(3000);
```

`max` raises the cache from its default 10,000 addresses, since a day's visitors outnumber an hour's. Each middleware keeps its own cache, so `cache: false` on the payout route leaves the pages' cache alone.

We ran it on October 24, 2026, with Express 5.3.0, `vpndetection-express` 2.0.19 and Node.js 24, sending `X-Forwarded-For` as a proxy on the same machine would. `23.165.56.3` is a Mullvad server in New York, from [Mullvad's server list](https://mullvad.net/en/servers):

```bash
curl -s localhost:3000/ -H 'X-Forwarded-For: 23.165.56.3'
```

```json
{"isVpn":true}
```

The first request took 140 ms and the next two 2 ms each, from the cache. A home broadband line answered `{"isVpn":false}`. A payout from the Mullvad server got `403` and `{"error":"access denied"}`, and so did one from a residential proxy exit; the home line got `{"paid":true}`.

## When the lookup is slow or down

To slow the API down, we pointed `api.vpndetection.io` at an address that never answers. To take it down, we pointed it at one that refuses the connection. Then we sent the Mullvad requests again:

| API | `GET /` | `POST /payout` |
|---|---|---|
| Slow | `200` after 1.0 s, `{"error":"network"}` | `403` after 2.5 s |
| Down | `200` in 34 ms, `{"error":"network"}` | `403` in 6 ms |

The page waited its one second and let the visitor in, and the payout waited the default 2.5 seconds and refused. A timeout comes back as `network`, the same kind as a refused connection, so one check covers both. Log it as a warning, not an error, as the Laravel fix did. A run of them is an outage worth an alert, and a single one is the timeout doing its job.

## How old a static list gets

A list updated every two months is a month old on an average day. On a median week, 5.8% of the addresses in a week-old copy of our VPN data were no longer flagged, and the copy lacked 5.1% of the current day's. If you look addresses up locally, take the [VPN IP database](https://vpndetection.io/databases/vpn-ip) daily, as often as it's rebuilt.

The middleware's options are in the [Express docs](https://docs.vpndetection.io/integrations/express), and every framework's middleware has its own timeout, cache and fail-closed switch. A cached answer costs no lookup, and [pricing](https://vpndetection.io/pricing) shows how many each plan includes.

