Skip to main content

Command Palette

Search for a command to run...

Stateful vs Stateless: The Architecture Decision You're Making Without Realising It

Updated
10 min read
Stateful vs Stateless: The Architecture Decision You're Making Without Realising It
G
Glory Praise Emmanuel is a software engineer who builds full-stack software solutions and systems. She writes about software engineering, Web3, AI, and career growth in tech. She is passionate about open source, developer education, and community building.

Most architecture mistakes don't happen when you're designing something complex. They happen at the start, when you're just trying to get the thing running, and you default to whatever you know without asking if it actually fits.

Stateful vs stateless is one of those decisions. It sounds academic until you're three months in and wondering why your transactional logic keeps breaking, why your edge function can't hold a database connection, or why your app falls over the moment traffic doubles.

Let's break it down properly.


What These Actually Mean

Stateful means context is preserved across operations. The system you're talking to remembers what happened in the last call and uses that to make sense of the next one. Database sessions are the cleanest example - when you open a transaction, the database holds your place: which rows you've touched, which locks you have, what to roll back if something goes wrong. WebSockets and gRPC streams work the same way. Both sides share a thread of context.

Stateless means every request is independent. The server doesn't remember you between calls. Each request has to carry everything needed to process it - auth, parameters, context. REST APIs are stateless at the protocol level: the request you sent two seconds ago has no bearing on the one you're sending now, at least not from the server's perspective.

A small but important note: stateless doesn't mean "no state anywhere." Your application probably still has a database, sessions, and caches. It means the request itself doesn't depend on server-side memory of prior requests. That's what makes it scale.


The Real Trade-offs

Stateful Stateless
Transactions and atomicity
Real-time push/subscriptions
Horizontal scaling
Edge / serverless friendly
Local complexity Higher Lower
Distributed complexity Lower Higher

That last row is the one most beginner takes miss. Stateless systems aren't simpler overall; they push complexity outward. You stop worrying about session management and start worrying about idempotency, retries, and how to coordinate things that used to be a single transaction. The complexity doesn't disappear; it moves.

Neither model is universally better. That's the point.


Where This Actually Shows Up

Take user management, for example, almost every app has it, and it forces the decision early.

Creating a user account involves multiple steps that have to succeed or fail together: validate input, write the user record, assign a default role, queue a welcome email. If the role assignment fails, the user record should roll back. That coordination needs state. You need a transaction, which means you need a stateful database connection.

Fetching a user's public profile is a different path. It's a read. It runs at the edge. It needs to be fast and globally distributed. A stateless HTTP query - through something like Neon's HTTP driver, PlanetScale's serverless client, or Supabase's REST layer - is a better fit. No connection to manage, no pool to exhaust, works anywhere.

Same data. Two different access patterns. Two different tools.

That's the architectural insight. You don't have to pick one, and you usually shouldn't.


Why This Matters Beyond "It Works"

Early in a project, it's tempting to pick one pattern and apply it everywhere. Full ORM with connection pooling for everything. Or everything goes through REST. It feels clean.

But consistency isn't the goal. Fitness for purpose is.

Stateless access for transactional logic breaks correctness. You can't guarantee atomicity across independent requests - one step succeeds, the next fails, and you're debugging partial state at 2 am.

Stateful connections in serverless environments break scalability. Functions spin up and down constantly. Persistent database connections don't gracefully survive that lifecycle, and connection pool exhaustion is one of the most common production problems that traces back to a single early architectural decision.

The mistake is optimising for one thing - correctness or scale - when the system needs both, in different places.


A Mental Model and a Checklist

Think of it like this:

Stateful = coordination and correctness.

Use it when operations depend on each other, when failure in one step should undo the others, and you need guarantees.

Stateless = speed and distribution.

Use it when the operation is self-contained, when latency matters, when the code needs to run anywhere - edge, serverless, globally distributed.

When you're designing a new piece of functionality, run through this:

  1. Does this operation span multiple steps that need to succeed or fail together? → Stateful.

  2. Does this need to run in a serverless or edge environment? → Stateless.

  3. Is this a read with no side effects? → Stateless is likely fine.

  4. Does failure here need to roll back something else? → Stateful.

  5. Am I tracking something asynchronous to completion (job, transaction, stream)? → Stateful.

  6. Am I just fetching data that's already settled? → Stateless.

Your write path almost always wants to be stateful. Your read-heavy, public-facing, or edge-deployed paths often want to be stateless. Most real systems run both deliberately.


This Pattern Is Everywhere in Web3 Too

If you build in Web3, you've been making this choice the whole time - you just might not have named it.

HTTP JSON-RPC is stateless.

When you call eth_call to read contract state or eth_getBalance to check a wallet, each request is self-contained. The node processes it and forgets you. This is fast, simple, and works well in serverless. For reads — balances, contract data, receipts — it's usually all you need.

WebSocket connections are stateful.

When you eth_subscribe to an event, you're holding a persistent connection open. The node pushes data to you as it happens. This is how you listen for real-time on-chain activity. You can't do this statelessly because stateless has no concept of "push"; you'd have to poll constantly, which is expensive and slow.

So in practice:

  • Reading a user's NFT balance when their wallet connects → stateless HTTP call.

  • Listening for a Transfer event to trigger a UI update in real time → stateful WebSocket.

  • Fetching historical transactions to render a table → stateless, query an indexer.

  • Watching for an on-chain event to trigger backend logic (mints, staking, governance) → stateful, hold the connection, react as blocks come in.

Smart contracts themselves complicate this nicely. The contract is stateful by nature - it stores balances, mappings, and roles on-chain, and that state persists across calls for everyone. But how you interact with it varies.

Read calls view and pure functions, when called externally via RPC, are stateless. No transaction, no gas, no coordination. Fire a request, get data back.

Writes are where it gets stateful. Submitting a transaction isn't fire-and-forget - at least not in production.

You need to track its lifecycle: submitted → pending → confirmed → finalised, or reverted.

On L1 Ethereum, that's roughly 12 seconds per block; on L2s and faster chains, it's seconds or sub-second. Either way, your architecture needs to account for it - either by holding a WebSocket open, or by running a background worker that watches for status.

The mistake a lot of devs make early on is treating write transactions like HTTP requests: send it and move on. They're not. They're async, non-deterministic, and the confirmation comes back when it comes back.


One Layer Deeper: Stateless Blockchains

The same trade-off is showing up at the protocol layer too, and it's worth knowing about even if you don't build L1s.

Today, every full Ethereum node holds the entire world state - every account balance, every contract's storage. To validate a new block, the node looks up the relevant data locally. That's a stateful node: it carries the world's data with it, and the storage cost grows forever.

Stateless clients flip that. Instead of every node holding all state, each block arrives with a cryptographic witness - a proof that says "here are the exact pieces of state this block reads and writes, and here's the math showing they're valid against the current state root." The node verifies the proof, applies the transition, and moves on. It doesn't need to store the state itself.

So the node becomes stateless in the same sense your edge function is stateless: each request arrives with everything needed to process it, and the participant doesn't need long-lived memory between requests. The state still exists - it's just held elsewhere (by archive nodes, provers, witness data) instead of by every validator.

The trade-offs even rhyme. Stateful nodes are fast locally but expensive to run, which centralises the validator set over time. Stateless nodes are cheap to run on modest hardware, which decentralises the network, but blocks get heavier, bandwidth goes up, and you pay in proof generation what you saved in storage. Coordination and local efficiency on one side, distribution and scale on the other. Same shape as the application-layer decision.

Two things worth keeping straight, though:

The chain itself is still stateful. A "stateless blockchain" doesn't mean the system has no memory; the state root is still global and persistent. What's stateless is the node's relationship to that state. The node stops being a state-holder and becomes a state-verifier. In the same way, an edge function querying Neon is a stateless client of a stateful database.

And this is about validation, not interaction. Even on a chain with stateless clients, your dApp still talks to nodes the same way - JSON-RPC for reads, WebSocket for subscriptions. Stateless clients change the node's internals, not the interface you build against. Your application-side decisions don't change.

If this sounds familiar, it's because cloud architecture went through this exact transition a decade ago, moving from "every server is a stateful pet" to "servers are stateless cattle, state lives in dedicated layers." Protocol design is now running the same playbook for the same reasons: you can't scale a system if every participant has to hold all the state.


The Bigger Point

By now, you might have noticed something. The same question, "Does this participant hold context, or does the context travel with each request? " keeps showing up at different layers of the stack. TCP vs UDP at the network layer. Database sessions vs HTTP queries at the application layer. Pet servers vs cattle servers in cloud architecture. Stateful nodes vs stateless clients at the protocol layer.

The specifics change. The trade-off curve doesn't. Coordination and correctness on one side, distribution and scale on the other. Recognising that pattern, and knowing which side of it each piece of your system needs to sit on, is most of what good system design actually is.

The systems you'll admire as you grow as an engineer aren't the ones that picked one pattern and stayed loyal to it. They're the ones that knew what each piece needed and chose accordingly, stateful where coordination matters, stateless where speed and reach matter, and both wired together carefully at the boundary.

That's what system design actually is. Not grand diagrams. The accumulation of small, deliberate choices made early, before the codebase gets heavy enough that changing your mind is expensive.