Context: Flick is an anonymous campus community platform for verified college students to post, discuss, and interact within their college network. You can read more about it here.

When I started building Flick, I knew rate limiting would need to happen eventually. Most tutorials start with the login endpoint, so that's where my head went first too. Brute force protection. Standard stuff.
Once I looked at the rest of the app though, login wasn't what worried me. The voting endpoints were.
Flick is anonymous. That's the whole point. If one person sits there spamming upvotes or downvotes, nothing crashes. The server keeps running. The numbers on posts slowly stop meaning anything. To me, that's worse than downtime because the app still works, but people stop trusting what they see.
The public read endpoints were the other thing I kept coming back to. I left them open without authentication because I wanted people to browse the platform before creating an account. That makes onboarding easier. It also makes scraping much easier.
At some point, rate limiting stopped feeling like another backend feature I had to add. Instead, I started asking myself a different question.
"If I wanted to abuse this platform, where would I start?"
That question made the implementation much easier.
I spent some time reading about the usual algorithms. Fixed Window. Sliding Window. Token Bucket. Leaky Bucket.
I wanted to understand the tradeoffs before picking one. Funny enough, I don't remember every detail anymore, but I do remember the conclusion.
I ended up using rate-limiter-flexible.
Flick runs on a single VM. Redis was already part of my stack. Writing another rate limiter didn't solve any new problem for me. It only added more code to maintain.
For the voting endpoints, a token bucket style approach made the most sense. Someone catching up on a discussion and voting on ten posts in a row isn't doing anything wrong. A script sending one request every couple hundred milliseconds is. I wanted enough room for the first case while slowing down the second.
While wiring everything together, one question kept bothering me.
What happens when Redis isn't available?
The limiter stores its counters in Redis. If Redis goes down, should every request start failing too?
I didn't like that idea.
Instead, I added a fallback. If Redis isn't available, the application switches to an in-memory limiter. The protection becomes weaker because the counters only exist inside one application instance instead of being shared through Redis. On a single server that's a tradeoff I'm happy to make.
A Redis restart shouldn't stop someone from opening the app.
Somewhere in the middle of building this, I realized I was expecting rate limiting to solve a problem it was never meant to solve.
It wasn't protecting the voting system.
It was protecting the API sitting in front of it.
Those sound similar, but they're different.
Rate limiting doesn't stop duplicate votes if the database allows duplicate rows.
It doesn't keep vote counts and karma consistent if they're updated outside the same transaction.
It doesn't tell me who did what if there's no audit log.
It doesn't fix stale vote counts sitting in Redis after someone changes their vote.
All of those problems still need their own solution.
Rate limiting only slows requests down. Everything after that still depends on the rest of the backend doing its job.
Looking back, I don't think choosing the algorithm was the interesting part.
Figuring out what needed protection was.
Once I answered that question, the rest of the implementation was mostly engineering work.
The limiter bought the system time.
The database, transactions, cache invalidation, and audit logs are what keep the data honest.