Bright ideas and techniques for building with Convex.
Profile image
Ian Macartney
a year ago

Anonymous Users via Sessions

Friends don't make friends log in

When building a new app, it can be challenging to build much behavior without the notion of a user. However, getting users to sign up for a new service before seeing any benefits is challenging. Signing up is cumbersome, and many people don’t want to share their personal information with a company until they want a long-standing relationship. In this post, we’ll discuss how to model ephemeral users, leveraging the session “middleware” introduced in this post. We’ll examine two approaches and discuss the challenges with anonymous auth.

Lightweight: using session IDs as user IDs

To illustrate the session middleware, I made a demo app in our convex-demos repo.

The app is a clone of our tutorial demo, which is a basic chat app. The tutorial generates a random user name on the client when the page loads and sends that string whenever it sends a message. This has these downsides, which we’ll aim to remedy:

  • Refreshing the page changes your user name.
  • An updated name isn’t reflected in past messages.

We can keep your name constant using sessions by storing it in the session data. Instead of initializing the random user name in the client, we initialize it when creating the initial session data:

// In convex/sessions.ts, run on server:
export const create = mutation(async ({ db }) => {
  return db.insert("sessions", {
    name: "User " + Math.floor(Math.random() * 10000),
  });
});

Persisting user name:

This way, when the user reloads the page, it will read the existing session ID from the browser (in localStorage or sessionStorage, whichever you configured) and use the existing session name.

Using anonymous users relationally:

In the users-and-auth demo, we solve updating names by associating each message with a user ID instead of the user name string. When listing the messages, it would look up the user name on the fly, so the messages always reflected the latest name. The session demo's approach is to store the session ID instead of the user ID and look up the name from session IDs. This means updates to the user name will be reflected in old messages:

Chat app in the sessions demoChat app in the sessions demo

Using the session data

To use the session ID, we store the session ID in the chat message:

export default mutationWithSession(async ({ db, session }, { body }) => {
  const message = { body, sessionId: session._id };
  await db.insert("messages", message);
});

And when we return the messages, we look up the sessions on the fly. Note we save one lookup by using the user’s session if they sent the message:

export default queryWithSession(async ({ db, session }) => {
  const messages = await db.query("messages").collect();
  return Promise.all(
    messages.map(async message => {
      const { sessionId, ...messageBody } = message;
      const author =
        session && session._id.equals(message.sessionId)
          ? session.name
          : (await db.get(sessionId)).name;
      return { author, ...messageBody };
    })
  );
});

You may notice we do all the lookups in parallel, which is wicked fast.

When to not use this approach

If you want a very lightweight solution, it doesn’t get much simpler than this. However, with its simplicity comes a limitation of what it can represent.

If your app will have logged-in users who will interact with anonymous users, it is awkward to have to look in two different places for users and store two different kinds of IDs depending on which type they are.

If you want to keep session IDs private, you should avoid passing around session IDs as user identifiers. For instance, you may want to keep it confidential if you store anything in the session related to a user’s permissions or auth tokens. While the sessions demo only passes down the messages with the session ID replaced with the latest name, it would be too easy to accidentally leak session IDs if they are your app’s user identifier.

To remedy these situations, consider a layer of indirection, where sessions store user IDs pointing to possibly-ephemeral users and are passed around freely.

Storing anonymous users in the “users” table

While building a multiplayer Dall-E-based game, one goal is to be able to play with the game without having to log in first. However, we also want users to be able to log in eventually, use their profile picture, access old games, or log back into games on a different device or browser tab.

Instead of passing session IDs around for the game, we create a user when the session is created and use that user ID. This way, we can pass around user IDs (which, in general, shouldn’t be treated as secrets) and still have a persistent shared identifier between the client and the server.

// Run on the server on initializing the session
export const create = mutation(async ({ db, auth }) => {
  const userId = await createAnonymousUser(db);
  return db.insert("sessions", { userId });
});

We will save the more complex topic of managing the hybrid of logged-in/out users in a future post, but as a teaser, here is how you could avoid creating an anonymous user if the user is logged in:

export const create = mutation(async ({ db, auth }) => {
  const identity = await auth.getUserIdentity();
  let userId = identity && (await getOrCreateUser(db, identity));
  if (!userId) {
    userId = await createAnonymousUser(db);
  }
  return db.insert("sessions", {
    userId,
  });
});

Summary

In this post, we looked at a couple of strategies for managing user information without requiring a login. Follow along with the multiplayer game using OpenAI here.

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started