DEV Community

the bomber
the bomber

Posted on

Building a Postgres-Backed Rate Limiter for Public Netlify Functions

I am Kaleb, a web developer running Dream Futures Tech right out of Pompano Beach, Florida. Recently, I added a public chat widget to my own business website to let visitors drop quick questions. Because the endpoint lives on a Netlify serverless function, it is completely open to the public internet. Within a few days of deploying it, I noticed automated script traffic trying to hammer the endpoint. I needed a reliable rate limiter without paying for expensive third-party edge security tiers.

Netlify Functions are great for hosting lightweight APIs, but they are stateless and spin down when idle. Relying on an in-memory cache inside the function container does not work because the container instances scale up, down, and across regions. Since my site already connects to a managed PostgreSQL database for internal tools and logging, using Postgres as a shared counter store felt like the most straightforward path.

Why Postgres Over Edge KV

I considered using Redis or edge-specific key-value stores, but my architecture already relies on a standard Postgres instance running on a small virtual private server at home. Adding another dependency felt like unnecessary overhead. In my judgment, the millisecond latency hit of querying Postgres over a TLS connection is entirely acceptable for a low-traffic business site, and it keeps my infrastructure footprint small and auditable.

The core logic is simple: track requests by client IP address within a rolling time window. If an IP exceeds, say, ten requests in a sixty-second span, the function rejects subsequent requests with a 429 status code.

Setting Up the Rate Limit Schema

I created a single table in Postgres dedicated to tracking these windows. It stores the client IP, a request count integer, and a timestamp marking the start of the current window.

CREATE TABLE rate_limits (
    client_ip VARCHAR(45) PRIMARY KEY,
    count INT NOT NULL,
    window_start TIMESTAMPTZ NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

When a request hits the Netlify function, the code extracts the client IP, checks this table, and either increments the counter or resets it if the window has expired.

The Netlify Function Implementation

Here is the actual JavaScript code running on my Netlify function. It uses the pg library to connect to the database. Notice how the SELECT query cleanly retrieves the existing rate limit record without any stray characters.

const { Client } = require('pg');

exports.handler = async function(event, context) {
    if (event.httpMethod !== 'POST') {
        return { statusCode: 405, body: 'Method Not Allowed' };
    }

    const clientIp = event.headers['x-nf-client-connection-ip'] || 'unknown';
    const now = new Date();
    const windowSizeMs = 60 * 1000; // 1 minute
    const maxRequests = 10;

    const client = new Client({
        connectionString: process.env.DATABASE_URL,
        ssl: { rejectUnauthorized: false }
    });

    try {
        await client.connect();

        const res = await client.query(
            'SELECT count, window_start FROM rate_limits WHERE client_ip = $1',
            [clientIp]
        );

        if (res.rows.length === 0) {
            await client.query(
                'INSERT INTO rate_limits (client_ip, count, window_start) VALUES ($1, 1, $2)',
                [clientIp, now]
            );
        } else {
            const row = res.rows[0];
            const windowStart = new Date(row.window_start);

            if (now - windowStart > windowSizeMs) {
                await client.query(
                    'UPDATE rate_limits SET count = 1, window_start = $1 WHERE client_ip = $2',
                    [now, clientIp]
                );
            } else if (row.count >= maxRequests) {
                await client.end();
                return {
                    statusCode: 429,
                    body: JSON.stringify({ error: 'Too many requests. Please wait a minute.' })
                };
            } else {
                await client.query(
                    'UPDATE rate_limits SET count = count + 1 WHERE client_ip = $1',
                    [clientIp]
                );
            }
        }

        await client.end();

        // Proceed with handling the chat message...
        return {
            statusCode: 200,
            body: JSON.stringify({ success: true, message: 'Message received' })
        };

    } catch (err) {
        console.error('Rate limit error:', err);
        await client.end();
        return {
            statusCode: 500,
            body: JSON.stringify({ error: 'Internal Server Error' })
        };
    }
};
Enter fullscreen mode Exit fullscreen mode

Handling Failures Gracefully

One risk of tying database operations directly to an HTTP API endpoint is database availability. If my home server drops offline, I do not want my entire public contact form or chat widget to break completely for legitimate users.

In my production setup, I wrap the database connection attempt in a try/catch block that fails open. If the Postgres query throws a timeout or connection error, the function logs the error to Netlify's console but allows the chat message to pass through. For my own business site, availability trumps strict rate-limiting enforcement during a rare infrastructure blip.

Maintaining this setup takes very little effort. Over the last few months, it has successfully blocked several automated form-spam scripts without requiring any complex cloud-native security products.

I build sites and automations for small businesses at dreamfuturestech.com.

Top comments (0)