DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

Why I Stopped Reaching for Redis on Solo Projects

  • Six Redis use cases replaced with SQLite, Postgres, and Edge KV

  • Rate limiting fits in one SQLite table with 40 lines

  • Postgres LISTEN handles pubsub without a second service

  • One place Redis still wins: high-throughput shared queues

I ran Redis on every solo project for three years out of habit. Then I audited what it actually did on one app, and five of the six jobs moved to tools I was already paying for. Here is where each one landed and the single case where I kept Redis.

The Habit I Never Questioned

Redis was the first thing I added to a new project. Before I had users, before I had a schema, I spun up a Redis instance because that is what the tutorials did. It felt like infrastructure hygiene. It was actually cargo culting.

The problem showed up on the bill and in the ops load. A managed Redis instance ran me around 15 EUR a month per project, and I had six projects. That is 90 EUR a month for something most of those apps barely touched. Worse, Redis was a second stateful service to monitor, back up, and reason about. When it fell over at 2am, I got paged for a cache that could have been rebuilt in 200 milliseconds.

So I made a list of every reason I reached for Redis. Six things came up: rate limiting, sessions, background queues, caching, presence tracking, and pubsub. Then I asked a blunter question for each one. Does this app have enough traffic to justify a dedicated in-memory store, or am I using a jackhammer to hang a picture frame?

For every solo project I run, the honest answer was the jackhammer. My busiest app peaks at maybe 40 requests per second. Redis is built for tens of thousands. I was provisioning for a scale I would celebrate reaching.

The mindset shift was treating Redis as an optimization, not a default. You do not start with the optimization. You start with the boring tool that is already in your stack, measure, and only add Redis when a real number tells you to. I run everything on Shopify for storefronts and small Node apps for the rest, and none of them were near the ceiling. If you want the fuller philosophy on picking boring tools first, Claude Blueprint covers how I make these calls before writing a line of code.

Rate Limiting And Sessions On SQLite

Rate limiting was the scariest one to move because everyone tells you it needs Redis. The classic pattern is INCR with an expiry, atomic and fast. I assumed SQLite could not touch it.

It can. A single table with a key, a count, and a window-start timestamp handles it fine at my traffic. On each request I upsert the row, check if the window expired, and reset or increment. The whole thing is about 40 lines. SQLite handles the atomic write because writes are serialized anyway. On a solo app doing dozens of requests per second, the write lock is never the bottleneck.

Here is the shape of it. A rate_limits table keyed by identifier (IP or user ID) plus endpoint. One column for hits, one for window_start. On request, I read the row, and if now - window_start is over 60 seconds I reset both fields, otherwise I increment hits and reject if it crosses the threshold. No expiry daemon, no eviction policy, no second service.

Sessions moved the same way. I used to store sessions in Redis because the docs said session stores should be fast and ephemeral. But my sessions live for days, not milliseconds. That is not ephemeral, that is durable state, which is exactly what a database is for. A sessions table with a token, a user reference, and an expiry timestamp does everything Redis did, and it survives a restart without me thinking about persistence config.

The bonus I did not expect: I can query sessions with SQL. "Show me every active session for this user" is one line. In Redis that meant maintaining a separate index set and keeping it in sync. Two things that can drift out of sync became one thing that cannot.

I wrote more about how far SQLite stretches before you need anything heavier in SQLite Is Enough, which walks through the services I moved off heavier databases entirely. The short version is that most solo apps never generate enough concurrent writes to hit SQLite's real limits, and the ones that do usually have a specific hot table you can peel off rather than a general scale problem.

Queues, Caching, And Edge KV

Background jobs were my second-most-common Redis reason. Send an email, resize an image, hit a webhook. The reflex is a Redis-backed queue with workers pulling tasks.

For a solo app, a jobs table in Postgres does this cleanly. Insert a row with a status of pending. A worker polls for pending rows using SELECT FOR UPDATE SKIP LOCKED, marks them running, does the work, marks them done. The SKIP LOCKED clause is the trick that lets multiple workers pull without stepping on each other, and Postgres has had it since version 9.5. I run one worker per app and it clears hundreds of jobs a minute without breaking a sweat.

The failure story is better than Redis too. A crashed job stays in the table with its error logged. I can query failed jobs, retry them, or inspect them weeks later. A Redis queue that loses a job to a crash just loses it unless you built an acknowledgment layer, which is more code than the whole Postgres approach.

Caching split into two answers. For per-instance caching, an in-process cache in Node memory beats a network round trip to Redis every time. Reading a value from a local Map is nanoseconds. Reading from Redis is a network hop of a millisecond or more. If the data fits in memory and one instance can serve it, keep it in the instance.

For caching that needs to be shared across edge locations, I use Edge KV (Cloudflare's key-value store or the equivalent on my host). This is where the story flips: for globally distributed reads, Edge KV genuinely beats a single Redis box because the data sits near the user. A config blob or a feature flag read from KV at the edge is faster than a request traveling back to my central Redis. I cache rendered fragments and API responses this way, with a TTL, and the origin only sees a request when the cache misses.

The honest tradeoff with Edge KV is write latency and eventual consistency. Writes propagate over seconds, not instantly. For caches and flags that is fine. For anything that needs a read-your-own-write guarantee, it is not, and that pushed me toward Postgres for those cases.

Presence, Pubsub, And The One Redis Still Wins

Presence tracking (who is online, who is typing) was my flashiest Redis use. Sorted sets with timestamps, expiring old entries, the works. On a solo app with a few hundred concurrent users, a presence table with a last_seen timestamp and a query for rows updated in the last 30 seconds does the identical job. I update last_seen on a heartbeat and treat anyone stale as offline. No sorted set gymnastics.

Pubsub was the one I was sure needed Redis. Turns out Postgres has LISTEN and NOTIFY built in. One connection issues LISTEN channel_name, another issues NOTIFY channel_name, 'payload', and the message arrives. I use this to push updates to connected clients over server-sent events. When a job finishes, the worker fires a NOTIFY, my web process is listening, and it forwards the event to the browser. Zero extra infrastructure.

The payload size limit on NOTIFY is 8000 bytes, which is plenty for an event ID that the client uses to fetch the full record. I never push large payloads through pubsub anyway, so this has not bitten me once.

Now the honest part. There is one place Redis still wins on my projects: a high-throughput shared queue where many producers and many consumers hammer the same stream and Postgres polling becomes the bottleneck. I hit this on one app that ingests webhook events in bursts, thousands in a few seconds. Postgres SKIP LOCKED polling started showing lock contention and the poll interval added latency. Redis Streams handled the same burst with lower latency and no polling, because consumers block on new entries instead of asking repeatedly.

That is the rule I landed on. Redis earns its place when you have genuine high-frequency contention on a shared structure that database polling cannot keep up with. That is a measurable condition, not a hunch. For that one app I run a single small Redis instance and nothing else touches it. The other five projects run zero Redis. I covered the broader "measure before you add infrastructure" idea in SQLite Is Enough, and it applies here exactly.

Bottom Line

I removed Redis from five of six projects and nothing broke. Rate limiting and sessions went to SQLite tables. Queues and presence went to Postgres. Pubsub uses Postgres LISTEN/NOTIFY. Shared caching went to Edge KV, which is genuinely faster at the edge than a central Redis box. Redis stayed on exactly one app, where real burst traffic made polling the bottleneck.

The saving was around 75 EUR a month and one fewer stateful service to page me at night. But the bigger win was mental. Every service you add is something to monitor, back up, and reason about when it fails. Fewer moving parts means more time building the thing people actually pay for.

None of this means Redis is bad. It means Redis is an optimization, and optimizations need a number that justifies them. Start with the boring tool in your stack, measure your real traffic, and add the fast in-memory store only when a metric tells you to. If you want the framework I use to make these build-versus-add decisions before writing code, Claude Blueprint lays out the whole process. Audit your own stack this week. You might find a jackhammer hanging a picture frame too.

This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)

Top comments (0)