Your in-memory rate limiter does nothing on serverless

    A Map at module scope looks like a rate limiter, passes a local test, and protects almost nothing in production.

    Your in-memory rate limiter does nothing on serverless

    The contact form on this site used to be guarded like this:

    const ipHits = new Map<string, { count: number; resetAt: number }>();
    
    const MAX_PER_HOUR = 3;

    Locally it works exactly as written. Four requests, and the fourth gets a 429. Ship it to any serverless platform and the guarantee quietly evaporates.

    Two problems

    The Map is per-instance. Your function runs on however many instances the platform decides to spin up. Each one gets its own module scope and its own empty Map. With n warm instances your "3 per hour" is really "3 per hour per instance", and you do not control n.

    Instances are disposable. They get recycled on deploys, on idle, under memory pressure — whenever. Every cold start hands an attacker a fresh counter. They do not even need to try: ordinary traffic patterns reset it for them.

    So the limiter reliably stops one thing — a single user double-clicking submit against a warm instance. That is worth something, but it is not what the code claims.

    Counting somewhere shared

    Since the submissions are already being written to a database, the count is available for free:

    const windowStart = new Date(Date.now() - 60 * 60 * 1000);
    
    const [ipCount, emailCount] = await Promise.all([
      ContactSubmission.countDocuments({ ip, created_at: { $gte: windowStart } }),
      ContactSubmission.countDocuments({ email, created_at: { $gte: windowStart } }),
    ]);

    Two indexes make it cheap:

    schema.index({ ip: 1, created_at: -1 });
    schema.index({ email: 1, created_at: -1 });

    Now the limit holds no matter which instance answers, and it survives restarts. The trade is two extra reads per submission — irrelevant on a contact form, and worth measuring on a hot path.

    The honest version

    Rate limiting needs state, and state has to live somewhere every instance can see: your database, Redis, or the platform's own limiter. A Map at module scope is a cache, not a limit.

    If you keep one anyway, at least name it for what it does. recentSubmissionsOnThisInstance is uglier than rateLimitMap, and it will stop the next person from trusting it.