Stateful vs Stateless Architecture: JWT, Sessions & Cookies Explained

Understand the difference between stateful and stateless systems. Learn how Cookies, Sessions, and JWT work with practical code examples and diagrams.

20 min read8 Feb 2026

What is State in Programming?

In programming, state refers to the condition of a system at a particular point in time. It is the data your application remembers.

Think about having a conversation with someone. If you ask "What was the answer?", they need to remember the previous question to give you a meaningful response. That memory is state.

State represents the data that is stored and used to keep track of the current status of the application.


Stateful Applications

A stateful application retains state or context about its interactions with users, systems, or components. The server keeps track of the state of each session or interaction and maintains that information based on the user's past requests.

The key characteristic of stateful applications is that the state is persisted on a durable storage solution, so the application survives a restart. Stateful applications save both past and present information.

How Stateful Architecture Works

In a stateful architecture, the server remembers the client's data. All future requests must be routed to the same server using a load balancer with sticky sessions enabled. This ensures the server is always aware of the client.

Loading diagram...

What are Sticky Sessions?

Sticky sessions is a load balancer configuration that routes a user's requests consistently to the same backend server for the duration of their session. This is different from traditional load balancing where requests can go to any available server in a round-robin pattern.

Because the session data lives on a specific server, the load balancer must track which user belongs to which server and route accordingly.

Problems with Stateful Architecture

Stateful architecture has two significant drawbacks:

  1. Not fault tolerant - If a server crashes, the user's session data is lost because that data only existed on that particular server.
  2. Difficult to scale - Adding or removing servers is complex because sessions are tied to specific servers. You cannot simply add more servers to handle increased load without migrating or replicating session data.

Use Cases for Stateful Applications

Stateful applications work well in several scenarios:

  • User-centric applications - Social media apps and e-commerce sites keep track of a logged-in user's session, including their preferences or shopping cart items.
  • IoT systems - Continuously send, receive, and analyze data in a feedback loop. Your home thermostat adjusts based on past temperature readings and your preferences.
  • AI and ML model training - Models learn from data and remember that data. During the training phase, the model is constantly changing and learning as parameters are being adjusted. However, once training is complete, the state of the model freezes and the model becomes stateless for inference.

Stateless Applications

A stateless application or process does not retain information about the user's previous interactions. There is no stored knowledge of or reference to past transactions. Each transaction is made as if from scratch for the first time.

An Important Clarification

The term "stateless" can be confusing because it seems to imply that no state exists at all. That is not always the case.

There are actually two forms of stateless architecture:

Form 1: Stateless servers with shared external storage

  • Individual servers do not hold any local state
  • They are interchangeable and any server can handle any request
  • The application still maintains state in a shared external store like Redis that all servers can access
  • The servers are stateless, but the system as a whole has state stored somewhere

Form 2: Truly stateless with JWT

  • No state is stored anywhere on the server side
  • The client carries all the necessary information in the JWT token itself
  • When the server receives a request, it verifies the token signature and reads the user data directly from the token payload
  • Nothing is looked up from any database or cache
  • The server has no memory of any user - it just validates the token and extracts the information it needs

So when we talk about stateless architecture, it can mean either:

  • Servers are stateless but state exists in shared storage
  • Everything is stateless and state is carried by the client

How Stateless Architecture Works

In a stateless architecture, HTTP requests from a client can be sent to any of the servers. The servers do not store session data locally.

State is stored in a separate database or cache like Redis that is accessible by all the servers. This creates a fault-tolerant and scalable architecture because web servers can be added or removed as needed without impacting state data.

Loading diagram...

HTTP is Stateless, TCP is Stateful

Looking at network protocols helps clarify this concept:

  • HTTP - A stateless protocol where each request is independent and carries no knowledge of previous requests
  • TCP - A connection-oriented and stateful protocol that maintains connection state throughout the communication

Benefits of Stateless Architecture

Stateless architecture offers two major advantages:

  1. Fault tolerant - If a server dies, another one takes over seamlessly. No session data is lost because the data lives in the shared cache.
  2. Easy to scale - You can simply add more servers behind the load balancer without worrying about session affinity.

Use Cases for Stateless Applications

  • REST APIs - Transfer a representation of the state of a resource to the requester. Each API call is independent and contains all the information needed to process the request.
  • Microservices - Allow each core function within an application to exist independently. Each service handles its own domain and communicates with others through well-defined interfaces.
  • Serverless architectures - Designed to respond to events in isolation. Functions spin up to handle a request and shut down afterward. They do not retain context from previous actions.
  • LLM inference - Large language models operate in a stateless manner during inference. The model weights are fixed and do not change between requests.

Stateful vs Stateless Comparison

AspectStatefulStateless
State RetentionStores information about the interaction in database or distributed memoryDoes not store information on server, transaction starts fresh
Session DependenceEach request depends on data from previous interactionsEach request is treated as new and independent
Storage DependenceRequires persistent storage on each serverState stored externally in shared cache or database
ScalabilityDifficult due to sticky sessions and server affinityEasy because any server can handle any request
Fault ToleranceLow, server crash means session data lossHigh, server crash does not affect user sessions

How Cookies Work

A cookie is a small piece of data that the server sends to your browser. Your browser stores it and sends it back with every future request to that server.

The Cookie Mechanism

The process works like this:

  1. When you first visit a website, the server includes a Set-Cookie header in its response
  2. This header contains the cookie data
  3. Your browser stores this cookie locally
  4. For all subsequent requests to the same website, your browser automatically includes the cookie in a Cookie header
  5. The server can now recognize these requests as coming from the same browser
Loading diagram...

Code Example: Setting and Reading Cookies

const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();
app.use(cookieParser());
app.use(express.json());

app.post('/login', (req, res) => {
  const { username, password } = req.body;

  // In production, validate against database
  if (username === 'john' && password === 'secret') {
    res.cookie('sessionId', 'abc123xyz', {
      httpOnly: true, // Prevents JavaScript access for XSS protection
      secure: true, // Only sent over HTTPS
      sameSite: 'strict', // CSRF protection
      maxAge: 24 * 60 * 60 * 1000, // 1 day in milliseconds
    });
    res.json({ message: 'Logged in successfully' });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

app.get('/profile', (req, res) => {
  const sessionId = req.cookies.sessionId;
  if (sessionId === 'abc123xyz') {
    res.json({ user: 'John Doe', email: 'john@example.com' });
  } else {
    res.status(401).json({ error: 'Not authenticated' });
  }
});

app.post('/logout', (req, res) => {
  res.clearCookie('sessionId');
  res.json({ message: 'Logged out successfully' });
});

app.listen(3000, () => console.log('Server running on port 3000'));

How Sessions Work

A session represents a more robust way to maintain state between requests. While cookies are stored entirely on the client side, sessions use a hybrid approach.

The Session Mechanism

  1. The server generates a unique session ID when a user logs in
  2. This session ID is sent to the browser as a cookie
  3. The server stores the actual session data associated with this ID on the server side (memory, database, or Redis)
  4. When the browser makes subsequent requests including the session ID cookie, the server looks up the associated session data
Loading diagram...

What is Stored on the Server?

When a user logs in, the server:

  1. Creates a session ID (a unique random identifier)
  2. Stores session-related data server-side:
    • User ID
    • Authentication status
    • Roles and permissions
    • Preferences
    • Other metadata like IP address or login time
  3. Sends only the session ID back to the client

This makes sessions:

  • Dynamic - The server can update or revoke session data instantly
  • Flexible - Permissions can be modified without issuing a new session

Code Example: Session-Based Authentication with Redis

const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');

const app = express();
app.use(express.json());

// Initialize Redis client
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect().catch(console.error);

// Configure session middleware with Redis store
app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: 'your-secret-key',
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 24 * 60 * 60 * 1000,
    },
  }),
);

// Simulated user database
const users = [
  {
    id: 1,
    email: 'john@example.com',
    passwordHash: '$2b$10$hashedpasswordhere',
    role: 'admin',
  },
];

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find((u) => u.email === email);

  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const isValidPassword = await bcrypt.compare(password, user.passwordHash);
  if (!isValidPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Store user data in session
  req.session.userId = user.id;
  req.session.email = user.email;
  req.session.role = user.role;
  req.session.loginTime = new Date().toISOString();

  res.json({ message: 'Logged in successfully' });
});

function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

app.get('/profile', requireAuth, (req, res) => {
  res.json({
    userId: req.session.userId,
    email: req.session.email,
    role: req.session.role,
  });
});

app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }
    res.clearCookie('connect.sid');
    res.json({ message: 'Logged out successfully' });
  });
});

app.listen(3000, () => console.log('Server running on port 3000'));

Sessions are stored in Redis, so even if the server restarts, users remain logged in. The server can instantly revoke a session by deleting it from Redis.


How JWT Works

With JWT, everything needed for authentication is stored inside the token itself. The server does not store any session-related data. It only holds the secret key to verify tokens.

JWT is Stateless

When a user logs in, the server generates a JWT containing:

  • User ID
  • Roles or permissions
  • Expiration timestamp
  • Any other metadata

This data is encoded and signed using a cryptographic algorithm like HMAC with SHA-256.

JWT Structure

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.signature

It has three parts separated by dots:

  • Header - Contains metadata about the token including the algorithm (alg) and type (typ)
  • Payload - Contains the user data (also called claims). There are three types:
    • Registered claims - Predefined claims like iss (issuer), exp (expiration), sub (subject), aud (audience)
    • Public claims - Custom claims defined in the IANA JSON Web Token Registry
    • Private claims - Custom claims agreed upon between parties
  • Signature - A cryptographic hash used for verification

Security Warning: The payload is only Base64Url encoded, not encrypted. Anyone can decode and read it. Do not put sensitive information (passwords, secrets) in the JWT payload unless the token is encrypted.

How the JWT Signature is Created

The server creates the signature by:

  1. Combining the Base64Url encoded header and payload
  2. Applying the HMAC-SHA256 algorithm using the secret key to create the signature
  3. Joining the header, payload, and signature with dots to form the final token
Loading diagram...

How JWT Verification Works

When the client sends back the JWT with each request, the server validates it by:

  1. Splitting the token into its three parts
  2. Recomputing the signature from the header and payload using the same secret key
  3. Comparing it with the signature in the token
  4. If they match, the token is valid. If they do not match, the request is rejected.
Loading diagram...

Code Example: JWT Authentication from Scratch

const express = require('express');
const crypto = require('crypto');
const bcrypt = require('bcrypt');

const app = express();
app.use(express.json());

const SECRET_KEY = 'your-256-bit-secret-key-here';

const users = [
  {
    id: 1,
    email: 'john@example.com',
    passwordHash: '$2b$10$hashedpasswordhere',
    role: 'admin',
  },
];

// Base64Url encode
function base64UrlEncode(data) {
  return Buffer.from(data)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Base64Url decode
function base64UrlDecode(str) {
  let padded = str.replace(/-/g, '+').replace(/_/g, '/');
  while (padded.length % 4) {
    padded += '=';
  }
  return Buffer.from(padded, 'base64').toString();
}

// Create signature
function createSignature(data, secret) {
  return crypto.createHmac('sha256', secret).update(data).digest('base64url');
}

// Create JWT token
function createToken(payload, expiresInSeconds) {
  const header = { alg: 'HS256', typ: 'JWT' };
  const now = Math.floor(Date.now() / 1000);

  const payloadWithExp = {
    ...payload,
    iat: now,
    exp: now + expiresInSeconds,
  };

  const encodedHeader = base64UrlEncode(JSON.stringify(header));
  const encodedPayload = base64UrlEncode(JSON.stringify(payloadWithExp));
  const dataToSign = encodedHeader + '.' + encodedPayload;
  const signature = createSignature(dataToSign, SECRET_KEY);

  return dataToSign + '.' + signature;
}

// Verify JWT token
function verifyToken(token) {
  const parts = token.split('.');
  if (parts.length !== 3) {
    throw new Error('Invalid token format');
  }

  const encodedHeader = parts[0];
  const encodedPayload = parts[1];
  const receivedSignature = parts[2];

  const dataToSign = encodedHeader + '.' + encodedPayload;
  const computedSignature = createSignature(dataToSign, SECRET_KEY);

  const signaturesMatch = crypto.timingSafeEqual(
    Buffer.from(receivedSignature),
    Buffer.from(computedSignature),
  );

  if (!signaturesMatch) {
    throw new Error('Invalid signature');
  }

  const payload = JSON.parse(base64UrlDecode(encodedPayload));
  const now = Math.floor(Date.now() / 1000);

  if (payload.exp && payload.exp < now) {
    throw new Error('Token expired');
  }

  return payload;
}

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find((u) => u.email === email);

  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const isValidPassword = await bcrypt.compare(password, user.passwordHash);
  if (!isValidPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = createToken(
    { userId: user.id, email: user.email, role: user.role },
    3600, // 1 hour
  );

  res.json({ token: token, expiresIn: 3600 });
});

function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authorization header required' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const payload = verifyToken(token);
    req.user = payload;
    next();
  } catch (err) {
    return res.status(403).json({ error: err.message });
  }
}

app.get('/profile', requireAuth, (req, res) => {
  res.json({
    userId: req.user.userId,
    email: req.user.email,
    role: req.user.role,
  });
});

app.listen(3000, () => console.log('Server running on port 3000'));

The JWT Logout Problem

You cannot instantly invalidate a JWT. If a user logs out, their token remains valid until it expires. The client should delete the token from storage, but there is no way to force invalidation on the server side.

Workarounds include:

  • Using short token expiration times (15 minutes)
  • Maintaining a token blacklist (adds state and somewhat defeats the purpose)
  • Implementing refresh token rotation

Sessions vs JWT: Choosing the Right Approach

ScenarioSessionsJWT
Instant logout neededWorks well, destroy session immediatelyToken remains valid until expiry
Multiple serversNeeds shared session store like RedisStateless, any server can verify
Mobile apps and SPAsCookie handling can be complexToken stored in app memory works well
Admin or banking portalsGood for tight control and audit trailsCannot revoke access instantly
Microservices and APIsHarder to pass cookies across domainsBearer token in header works well
Dynamic permissionsUpdate session data anytimeMust issue new token

Use JWT for scalability and APIs where statelessness is beneficial, such as mobile apps and microservices. Use sessions when you need tight control, dynamic permissions, or instant logout, such as banking or admin portals.


Summary

ConceptDescriptionData LocationBest Use Case
StatefulServer remembers user stateServer memory or databaseTraditional web apps
StatelessServer does not store user stateExternal cache or clientAPIs and microservices
CookieSmall data stored in browserBrowser storageSession IDs and preferences
SessionServer-side data linked to cookieServer, database, or RedisApps needing instant logout
JWTSelf-contained signed tokenClient holds the tokenScalable APIs and mobile apps

References