Your AI-Built App Assumes One User — What Breaks With Two

·6 min read·Updated
vibe-codingaiconcurrencysecuritysqlitearchitecture

An LLM writes code for the situation it can see: you, alone, clicking through your own app. That produces a specific and consistent blind spot — per-user state ends up in module-level variables, and your app is one container running one process, so those variables are shared by everyone who visits. The symptom usually isn't a crash. It's user A seeing user B's uploaded file, or getting user B's answer, or a counter that jumps around. It survives all your testing because you are user one, and it appears the first time two people use the app in the same minute. Finding it takes about ten minutes: open two private browser windows and use the app as two different people at the same time.

Why your testing never catches it

Every test you've run has one visitor. Click through it as carefully as you like and shared state behaves perfectly, because there's nothing to share it with.

That's also why the bug arrives at the worst possible moment: the day you show it to people. Concurrency problems scale with popularity, so the first real traffic your project gets is also the first time it misbehaves.

Nothing in the deploy tells you either. The build passes, the container runs, and the platform's view of health is just whether the container is up.

The four patterns to look for

Module-level variables holding per-request state. The big one:

# Looks harmless. Is shared by every visitor.
current_file = None
chat_history = []
user_settings = {}

Anything assigned at module level lives for the life of the process, and there is one process. Two people uploading at once and the second overwrites the first — then the first downloads the second's file. This is a privacy incident, not a glitch, and it's worth treating as one when you find it.

Fixed file paths. /tmp/upload.png, output.pdf, data.json. Same collision, on disk. Two requests, one filename, whichever finishes last wins. Use a per-request random name, and remember anything outside a volume disappears when the container is recreated anyway.

Read-modify-write without a lock. Load a JSON file, append an item, write it back. With two requests interleaved, one of the writes silently disappears. Generated code does this constantly for counters, lists and simple stores, because with one user it's correct.

Slow work inside a request handler. A model call, a big file parse, a sleep. On a machine with cores to spare this is merely rude; here your CPU allocation is a time budget, so one visitor's slow request measurably delays everyone else's. In async code the classic version is a blocking call — time.sleep, or a synchronous HTTP library — inside an async def, which stalls the entire event loop rather than one request.

One container makes this worse, and also simpler

Two things follow from the architecture, and they point in opposite directions.

Worse: there is no isolation to hide behind. Some platforms accidentally paper over shared-state bugs by running several instances, so each one only serves a fraction of users and collisions are rarer. Here there's one container and no horizontal scaling — every request goes to the same process, so a shared global is shared with everyone, every time. The bug is fully expressed rather than intermittent.

Simpler: because there's exactly one process, an ordinary in-process lock is a complete solution. You do not need Redis, a distributed lock, or any coordination mechanism — a threading.Lock or an asyncio.Lock around a read-modify-write genuinely fixes it. That's a real advantage, and it's the same reason you probably don't need Redis for caching.

So the fix is usually smaller than the internet suggests. Most advice about concurrency assumes a fleet you don't have.

SQLite under two users

If your app writes to SQLite — and generated apps very often do — concurrency is where the defaults show.

SQLite's own documentation is precise about what WAL mode changes: "WAL provides more concurrency as readers do not block writers and a writer does not block readers. Reading and writing can proceed concurrently." But it's equally clear about the limit: "since there is only one WAL file, there can only be one writer at a time" (SQLite docs).

Practically, that means:

  • Turn on WAL. It removes the reader-blocks-writer problem, which is most of what you'll hit.
  • Set a busy timeout, so a request that arrives during a write waits briefly instead of failing immediately with a "database is locked" error.
  • Keep writes short. One writer at a time is fine when each write is milliseconds and painful when a transaction stays open while you call an API.
  • Don't open a new connection per request and leave them around.

None of this makes SQLite a bad choice — it's the right default for a single-container app. It just needs the three lines of configuration that a generated app won't have.

Finding it in ten minutes

Two private windows. Open your app in two separate private/incognito windows, sign in as two different people if it has accounts, and do the same thing in both at once. Upload in one, then look at the other. Most shared-state bugs show up immediately.

Read the top of your files. Scan for anything assigned at module level that isn't a constant. A list, a dict, a None that gets set later — each is a candidate. Constants are fine; mutable state is not.

Ask for the audit. If an assistant wrote the code, it will find these when asked directly. "List every module-level mutable variable in this file and say whether it holds per-user state" is a better prompt than "check for bugs", because it names the thing to look for. Same principle as writing your constraints down for future changes.

What to do instead

  • Put per-user state in the session or the database, keyed by user. Not in a module-level variable.
  • Pass state through the call, rather than reaching for a global inside a helper.
  • Lock read-modify-write, with an ordinary in-process lock.
  • Give every temporary file a unique name, and clean it up.
  • Move slow work out of the request — return quickly, do the work in a background task.
  • Then test with two windows again, because the fix deserves the same check as the bug.

One thing you can safely keep in a global: things that are the same for everyone, like a loaded configuration or a client object. The test is whether the value would differ between two users. If it would, it can't live there.

FAQ

Why does my AI-built app show one user another user's data?

Per-user state is almost certainly in a module-level variable. There's one process serving everyone, so every visitor shares it.

Why didn't this show up in testing?

Because you were the only user. Shared-state bugs need two simultaneous requests to appear.

Does running more instances fix it?

No, and you can't anyway — an app is a single container. More instances would only make the bug intermittent, which is worse.

Do I need Redis for locking?

No. One container means one process, so an in-process lock is a complete solution.

Is SQLite safe with several users?

Yes, with WAL enabled and a busy timeout set. Readers and writers can work concurrently, but only one writer at a time.

How do I find these quickly?

Open two private browser windows and use the app as two people at once, and scan your files for mutable variables declared at module level.

Wrapping up

This is the most predictable category of bug in AI-written applications, because it comes from the one thing the model couldn't observe: someone else using your app. It's worth checking before you share the link rather than after, since the failure mode is quiet and the data involved is other people's. The good news is that the fix is unusually contained here — one container, one process, one lock — and once per-user state lives where it belongs, the second user is no different from the first.