> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Session storage gets complicated faster than you think

> Explore session storage tradeoffs across files, PostgreSQL, Redis, and Valkey, from expiration and cleanup to memory limits. Test your options on Upsun Cloud.


export const PostMeta = ({data = {}}) => {
  const {author, date} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const toSlug = value => String(value).toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const url = `/posts/authors/${toSlug(slug)}`;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna",
      "github": "jackrabbithanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "maz-mohammadi": {
      "name": "Maz Mohammadi"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip",
      "noindex": true,
      "github": "sumerc",
      "linkedin": "https://www.linkedin.com/in/sumer-cip/"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
    </div>;
};

<PostMeta data={{ author: ["ralt"], date: "2026-09-30T09:00:00.000Z" }} />

You're building a PHP application, and you need to remember who's logged in between requests. You enable sessions, store a user ID, and carry on with the next feature. PHP writes the session data to a file in the directory set by [`session.save_path`](https://www.php.net/manual/en/session.configuration.php).

There isn't much to configure. Your application runs on 1 instance. The folder is writable, and the next request can read the session. You might not even have looked at where that folder lives.

A while later, you want to run a second instance of the application. Before adding it, you check what the 2 instances need to share. The database is already external. Uploaded files need some attention. And then there are those session files.

Each instance would have its own directory. A session created on the first wouldn't exist on the second, even though the browser sends the same cookie to both. Sticky sessions would send each user's requests to the same instance. But if that instance goes away, their session goes with it. Shared file storage could work, too, though its performance can be a problem for sessions.

Since both instances already connect to PostgreSQL, putting the sessions there seems reasonable. Your framework has a database session handler. You configure it, create the table, and both instances can now read the same sessions.

## Keeping the session table around

The table doesn't need much: a session ID, some serialized data, and an expiration timestamp. The session ID is the primary key. For each request, the handler reads the row for that session ID and checks the expiration timestamp.

For PostgreSQL, that lookup might look like this:

```sql theme={null}
  SELECT data
  FROM sessions
  WHERE session_id = $1
    AND expires_at > CURRENT_TIMESTAMP;
```

You also need to remove the rows you no longer use. PostgreSQL and MariaDB don't automatically delete rows after a set lifetime, often called a time to live (TTL). Adding an `expires_at` column doesn't change that. Something still needs to delete the expired rows.

A scheduled query takes care of that:

```sql theme={null}
  DELETE FROM sessions
  WHERE expires_at <= CURRENT_TIMESTAMP;
```

An index on `expires_at` helps find expired rows, especially when most of the table hasn't expired yet. You schedule the query every hour and let it run.

That doesn't give your users an extra hour of session lifetime. The handler checks expiration on every read. A session expiring at 14:03 stops working then, even if its row remains until 15:00. You can run cleanup less often, provided keeping those expired rows meets your retention requirements.

This setup may be all you need. It uses a database you already operate, and your framework may handle most of the details. Convenience is a good reason to choose it.

As the application grows, though, that scheduled query has more to do. Finding expired rows is only part of the work. Deleted rows still occupy space until PostgreSQL's [vacuum process](https://www.postgresql.org/docs/current/routine-vacuuming.html) makes it available for reuse. That cleanup, along with session writes and index updates, competes with the rest of your application for database resources.

You can tune the cleanup frequency or delete in batches. If enough data expires together, there's another option worth considering.

## Deleting an hour at a time

With [PostgreSQL range partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html), you can group sessions by `expires_at` into hourly or daily partitions. Once everything in a partition has expired, you drop the partition. You avoid deleting rows individually and vacuuming afterward. For large batches of expired sessions, that can be much cheaper.

You still check expiration during reads. A daily partition changes when you reclaim storage, not when a session stops being valid.

You do need to create new partitions ahead of time and remove old ones. Dropping a partition also takes locks, which can make other queries wait. Extending a session's expiration may move its row between partitions. Looking up a session by ID alone may require searching several partitions. And if you partition by `expires_at`, the table's primary key must include that column.

That extra work can pay off when you have lots of sessions to remove. At a smaller scale, the original table may be enough.

If you expect to create and expire lots of sessions, planning for partitioning early can save you a migration later. You can migrate without discarding every session, but preserving them takes planning. Asking everyone to log in again might be acceptable for your application. It might also be a rather awkward conversation with the business.

## Letting the store handle expiration

By now, you've spent a fair amount of time thinking about how to remove sessions. A store that already handles expiration starts to look appealing.

The access pattern fits a key-value store well. You have a session ID, some data, and a lifetime. You aren't usually joining sessions against other tables or searching their contents. Redis and Valkey fit this model, and many frameworks already support them as session backends.

With Redis, the handler can set a [TTL on each key](https://redis.io/docs/latest/commands/expire/). If user activity extends the session, it can refresh that TTL. Expired keys become unavailable, and the store handles cleanup. That's one less scheduled job to maintain.

On Upsun Cloud, you can try this by adding a service to a preview environment and changing the session handler there. For sessions that need to survive a service restart, choose [persistent Redis](/docs/add-services/redis#persistent-redis) or [persistent Valkey](/docs/add-services/valkey#persistent-valkey). The ephemeral variants are intended for data you can afford to lose.

But Redis keeps its entire dataset in memory, even with persistence enabled. Persistence adds a disk copy for recovery after a restart. Every session still needs room in RAM while the service runs. The same applies to Valkey.

With long-lived sessions, you can need a lot of memory. You're keeping sessions for users who may not have visited for weeks.

Suppose you create 100,000 new sessions each day and keep them for 30 days. Without early deletion, that's roughly 3 million sessions. If each session contains 2 KB of serialized data, that's about 6 GB before counting keys and Redis's own overhead. You also need spare memory for the service to run and the data to grow.

Most of those users don't need to be on your website today. Their sessions still occupy memory. Keeping sessions longer increases memory use even if the number of users online stays the same.

PostgreSQL and MariaDB also need memory to perform well, but they can store more data than fits in RAM. They keep it on disk and load parts into memory as needed. With Redis, you need RAM for all of it. You may run out of memory long before you would have run out of disk space.

On Upsun Cloud, `maxmemory` sets how much memory Redis can use for its dataset. The platform calculates it from both the memory and disk you allocate, as explained in the [persistent Redis documentation](/docs/add-services/redis#persistent-redis). The container also needs memory for work beyond storing sessions.

## The Redis instance you already have

You might already be running Redis for caching. Using it for sessions would save adding another service, much as using PostgreSQL did earlier.

For cached data, reaching the memory limit can be normal. An eviction policy removes entries, and the application rebuilds them when needed. You still check how well the cache performs, but users may never notice an evicted entry.

A session is harder to rebuild. If its key disappears, the application may have to ask the user to log in again. Any temporary state stored only in that session disappears with it.

An [eviction policy](https://redis.io/docs/latest/develop/reference/eviction/) can remove keys before their TTL expires. Choosing `noeviction` prevents that. But when Redis reaches the limit, writes that need more memory fail instead. Your application then has to handle failures to create or update sessions.

Sharing the service also makes the memory graph harder to read. Session data can gradually occupy more memory while cached data gets evicted to make room. Total memory usage looks similar, but the cache becomes less effective. That graph alone won't tell you how close sessions are to filling the instance. You need to measure session memory separately to know when to add capacity.

A cache can stay within a fixed memory budget by evicting entries, provided the resulting hit rate remains acceptable. Sessions need enough memory for all the data you retain, plus room to grow. As that total grows, you may need to upsize the session store while keeping the cache the same size.

Separate logical databases within the same Redis instance don't give you independent memory limits, eviction policies, or scaling. Separate services do. A dedicated session service lets you monitor its memory use and set alerts before it fills up. You can then add capacity for sessions without resizing the cache.

That's another service to size and monitor. It can still be a good choice, especially if your framework handles renewal and concurrent requests well. You no longer maintain a SQL cleanup job, but you do need to watch memory use.

## Trying it with your application's data

Before changing anything, it's worth trying these options with the amount of session data your application actually keeps. Each comes with trade-offs. Your existing database saves you another service, but session writes and cleanup compete with application queries. Partitioning makes bulk cleanup cheaper, but adds work around partition management and session lookups. Redis or Valkey handles expiration for you, but requires memory for every retained session and monitoring to know when to upsize.

You can explore those options in an [Upsun Cloud preview environment](/docs/environments/actions), with a copy of the parent environment's data where appropriate. Add the service or change the schema there, then test with a realistic number and size of sessions. A handful of test logins won't tell you much about a month's worth of retained sessions.

Run requests across multiple application instances. Check renewal, concurrent updates, expiration, and behavior after a restart. If you're using PostgreSQL, run cleanup while the application handles traffic and measure its impact. With Redis or Valkey, measure memory use and leave room for more sessions.

Once you're happy with the result, merge the code and configuration with a plan for existing sessions. A merge doesn't convert the contents of a PostgreSQL table into Redis keys. You'll need to migrate the existing sessions or accept that users will have to log in again.

The folder may have been enough when you started, and your framework's database handler may be enough for years. The trade-offs change with session volume, lifetime, and the work your team wants to take on. Leave yourself room to try something else when the application needs it.
