On this page
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). 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 says VPN and proxy IPs "change fast, sometimes within minutes". The 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 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:
npm install express vpndetection-expressimport 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:
curl -s localhost:3000/ -H 'X-Forwarded-For: 23.165.56.3'{"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 daily, as often as it's rebuilt.
The middleware's options are in the Express docs, and every framework's middleware has its own timeout, cache and fail-closed switch. A cached answer costs no lookup, and pricing shows how many each plan includes.


