Single-node Rate Limiting Without Redis
How OpsKitPro protects public diagnostic APIs with a pragmatic in-memory limiter before adding Redis, API keys, or paid quotas.
This page is organized as a full read-through, from background to implementation and usage.
Public APIs need protection before they need billing
OpsKitPro exposes public diagnostic APIs because web tools become more useful when they can also be called from scripts, CI checks, and AI agents.
But the moment a diagnostic tool becomes a public API, it also becomes a public cost surface.
A DNS lookup is cheap. A full website diagnostic that touches DNS, HTTP, redirects, TLS, RDAP, security headers, and CDN detection is more expensive. An HTTP check can also follow redirects and touch third-party targets. Treating all of those endpoints as if they cost the same would be lazy engineering.
So before adding API keys, Stripe, Redis, or any complicated account system, OpsKitPro needed a basic rule:
Anonymous public APIs should be useful, but not unlimited.
Why not start with Redis?
Redis is a good tool. It is also not free in operational complexity.
For a one-person product, every extra service means another thing to provision, monitor, secure, back up, upgrade, and recover during incidents. If the product is already running as a single standalone Node process on Lightsail behind Cloudflare, an in-memory limiter can be the correct first step.
The current production model is simple:
Cloudflare -> Lightsail -> Next.js standalone NodeThere is one application instance. That means a local memory bucket is consistent enough for anonymous per-IP protection. It does not solve every future quota problem, but it solves the current abuse-control problem without introducing a new dependency.
That tradeoff is intentional.
Cost classes are better than one global number
The first version of the public API used a simple mental model: all endpoints were described as 60 requests per minute.
That was easy to explain, but it was not accurate enough once the diagnostic API became richer.
OpsKitPro now uses cost classes:
- Low-cost lookups: DNS lookup and IP lookup allow 60 requests per minute.
- Medium-cost checks: HTTP check and trace allow 15 requests per minute.
- Full diagnostics: Website diagnostic allows 3 requests per minute.
This matches how the system actually behaves. A cheap lookup should remain friendly for scripts. A full diagnostic should be protected because it fans out into multiple live probes.
Rate limiting should reflect cost, not just route count.
The limiter shape
The current limiter is intentionally small:
- key by cost class, route, and hashed IP
- keep counters in an LRU cache
- use a fixed time window
- return remaining quota and reset time
- support disabling in controlled environments with
RATE_LIMIT_ENABLED=false
The IP is hashed before becoming part of the cache key. That is not a privacy framework by itself, but it avoids keeping raw IP addresses in the limiter key.
The important part is that the limiter is attached at the route boundary. A request should be rejected before it triggers expensive diagnostic work.
A useful 429 response is part of the API contract
Rate limiting is not only a backend guard. It is also a developer experience.
When a client is rate-limited, it should receive enough information to back off correctly. OpsKitPro returns a 429 Too Many Requests response with headers such as:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-After
The JSON body also uses a stable error code:
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry later.",
"retryAfter": 42
}
}That structure matters because scripts and agents should not have to parse prose to understand what happened.
Why route-level tests matter
It is easy to unit test the math of a rate limiter and still break the public API behavior.
For example, a route might correctly count requests but forget to send Retry-After. Or it might return a generic error shape that does not match the rest of the API. Or a validation error might happen before the limiter, allowing repeated expensive setup work.
That is why OpsKitPro includes a route-level 429 contract test for the HTTP check endpoint. The test verifies the real route response: status code, headers, and public error body.
For public APIs, the contract is the feature.
When this stops being enough
Single-node memory limiting is not the final architecture.
It stops being enough when:
- the app runs on multiple instances
- paid API keys need persistent quota
- users need monthly usage history
- limits must survive process restarts
- abuse patterns need cross-region or cross-node enforcement
- enterprise customers need account-level policies
At that point, Redis, a database-backed quota system, Cloudflare rules, or an API gateway may become the right layer.
But adding those layers before the product needs them would create maintenance cost without improving the current user experience.
The OPC lesson
For a one-person company, architecture should match the stage.
The first public API protection layer does not need to be a perfect distributed quota platform. It needs to keep the free API usable, protect expensive probes, communicate clearly with clients, and leave an obvious upgrade path.
That is what the current OpsKitPro limiter does.
Start small. Make the contract explicit. Upgrade only when the product pressure becomes real.