Skip to content

ReplDB to Valkey: your Replit key-value store, grown up

7 min readReplitValkeyMigrationDatabases

ReplDB is the smallest database people actually build on. Not a scratch file, not a demo: apps with real users keep their whole state in a key-value store that tops out at 5,000 keys and answers a single HTTP endpoint. I have a lot of time for that. It is one environment variable, no schema, no connection to manage, and await db.set('score', 42) works about four seconds after you decide you need persistence. As on-ramps go it is close to ideal.

The trouble is that it is an on-ramp with a wall at the end of it, and the wall is invisible until you are already leaning on it.

What ReplDB actually is

The legacy Replit Database lives behind a secret environment variable, REPLIT_DB_URL, pointing at something like https://kv.replit.com/v0/<token>. That URL is the address and the credential at the same time, which is why it is in Secrets and not in your code. Talk to it over plain HTTP and it stores strings.

The ceilings are published and they are firm:

LimitValue
Store size50 MiB
Keys5,000
Key length1,000 bytes
Value size5 MiB

Values are plain strings. There are no TTLs, and there are no hashes, lists, or sets. If you have been storing JSON, you have been storing JSON.stringify output and parsing it back yourself, which works fine and is worth remembering when you read the migration section below.

The four ways apps outgrow it

Five thousand keys is a lot until keys are per-something. A key per user, per session, per uploaded file, per day of history: any of those turns 5,000 into a number you can see from where you are standing. The failure is not gradual either. You are fine, and then writes start failing, and the app that was working on Friday is not working on Monday because somebody shared it.

Nothing expires. This is the one I would fix first. Sessions, caches, rate-limit counters, password-reset tokens and email confirmations all want to disappear on their own. Without expiry you write a sweeper, and a sweeper against a 5,000-key store burns its budget listing keys just to find the dead ones. Every one of those cases is a single argument in Redis.

Everything is a string, so nothing is atomic. A view counter becomes read, parse, add one, stringify, write. Two requests land at the same time and one of the increments vanishes. A queue becomes a JSON array you rewrite in full. A leaderboard becomes a sort in application code over every key you can find. Redis has INCR, LPUSH, SADD and sorted sets precisely because these are the operations that go wrong when you build them out of strings.

The URL is the only door, and it rotates. There is no client, no CLI, no dashboard to open when something looks wrong. Debugging means echoing a secret in the Repl shell and reading raw HTTP responses. Nothing outside that Repl can reach the data conveniently: no cron on a VPS, no local script, no colleague with a GUI. That is a reasonable design for a store meant to serve one Repl, and it is a wall the day the data matters to anything else.

The move is one paste

Your app stays on Replit for this. Nothing about the deployment changes except which URL your code reads.

  1. Create a Valkey database on Layerbase Cloud. Redis works identically if you would rather have that name in your stack. Valkey sits on the free tier, so the landing spot is $0.
  2. Open the new database and go to its Migrate tab. Pick Replit.
  3. In the Repl shell, run echo $REPLIT_DB_URL and copy what it prints. Do this now rather than reusing a value you saved earlier: the token rotates, and a stale URL is the single most common way this fails.
  4. Paste it into the form, tick the acknowledgement, and click Import.

Every key is copied as a plain string SET. There are no TTLs on the source, so nothing has to be reconstructed, and the copy is exact. The source is read once and never modified, and the URL is used for the copy rather than stored.

Two rules the importer enforces that are worth knowing before you start:

  • The target must be empty. If the Valkey database already holds keys, the import is refused rather than merged into. Create a fresh database for it.
  • The copy is one directional. Nothing is written back to Replit, so your old store is still intact afterwards, which is your rollback.

I ran this end to end against a real Repl before writing it up. Eight keys, byte for byte identical on the other side, including a key literally named key with spaces (ReplDB URL-encodes those, and they come out with the spaces intact), keys with colons in them, unicode values, and stringified JSON. A store sitting at ReplDB's caps copies in seconds, because 50 MiB is not enough data for the copy to be slow.

If you also have a Postgres on Replit, that one moves separately and the whole flow is in Migrating from Replit to Layerbase. Both importers live at layerbase.com/migrate/replit.

What changes in your code

Not much, and that is the point. @replit/database is a get/set client, and those are GET/SET:

ts
// Before
import Database from '@replit/database'

const db = new Database()

await db.set('user:42:prefs', JSON.stringify(prefs))
ts
// After
import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

await redis.set('user:42:prefs', JSON.stringify(prefs))
const prefs = JSON.parse((await redis.get('user:42:prefs')) ?? '{}')

Set REDIS_URL in the Repl's Secrets to the connection string from the database's connection panel and redeploy. Check what your version of @replit/database returns from get as you go: older releases hand back the value directly and newer ones wrap it, and if your code already unwraps it, the Redis version needs one fewer step.

The reason to do this is not the copy, it is what is available afterwards. The expiry you could not have:

text
SET session:abc '{"user":42}' EX 3600

The counter that stops losing increments under concurrency:

text
INCR views:post:17

And a leaderboard that is a data structure rather than a sort you wrote:

text
ZADD leaderboard 4820 ada
ZREVRANGE leaderboard 0 9 WITHSCORES

Plus pub/sub, hashes for objects you want to update one field at a time, and lists for the job queue you were about to build out of a JSON array. None of that is required on day one. It is there when you need it, which is the difference between a key-value store you grow into and one you grow out of.

When ReplDB is still fine

I would rather you kept it than moved for the sake of moving. Stay where you are if the store serves exactly one Repl and will die with it, if you are holding a few hundred keys and the count does not grow with your users, or if nothing in the app needs something to expire on a schedule. Zero configuration is a genuine feature, and a connection string plus a client is a real cost against it, small as it is.

The specific signals that the wall is close: you have written code to delete old keys, you have hit a write that failed for a reason you had to look up, you have simulated a counter or a queue on top of a string, or something outside the Repl now needs the same data. Any one of those and the move pays for itself the same afternoon.

After the copy

Leave the ReplDB store alone for a week. The import does not touch it, so it is still sitting there complete, and pointing your environment variable back is the cheapest rollback available to you. Once the app has run through a real weekend against Valkey, clear it out if you want to.

If you are still working out which of Replit's databases you are even holding, Replit database alternatives lays out all three and makes the honest case for staying on each. And if the store you care about is Postgres rather than keys, the migration guide covers the production and development paths, plus the driver question that catches Agent-built apps.