DEV Community

Cover image for Stop Managing Browser Sessions Yourself. Use Steel and Convex
Niksa Kuzmanic
Niksa Kuzmanic

Posted on

Stop Managing Browser Sessions Yourself. Use Steel and Convex

Most browser automation setups work fine until they don't.

You get Puppeteer running locally. It works. You ship it. Then a server restarts mid-session, a user's data gets lost, and you realize you have no idea which sessions belong to which users or what state they're in.

This is not a Puppeteer problem. It's a missing infrastructure problem.

Browser sessions are stateful. They have owners. They have lifetimes. They need to be tracked and cleaned up. None of that is built in anywhere.

Steel and Convex fix this together. Here's how.

The actual problem

When you run Puppeteer yourself, the session state lives in memory on your server. If that server crashes, the sessions are gone and you have no record of what happened.

There's no built-in concept of ownership either. Any session could belong to any user. Scoping them is something you build yourself, usually with naming conventions that break under pressure.

And debugging is guesswork. You have logs if you remembered to write them. You have no replay, no live view, no structured history.

Why session states are not enough

What Steel does

Steel is a managed browser session API. You call it to create a session, you get back a WebSocket URL to connect Playwright or Puppeteer, and Steel handles everything else.

{
  "externalId": "3afa1818-5c69-4dda-843c-d564382d53f5",
  "status": "live",
  "websocketUrl": "wss://connect.steel.dev?sessionId=...",
  "debugUrl": "https://api.steel.dev/v1/sessions/.../player",
  "sessionViewerUrl": "https://app.steel.dev/sessions/..."
}
Enter fullscreen mode Exit fullscreen mode

The debugUrl is a live player you can open in your browser while the session is running. This alone saves hours of debugging time.

Steel owns the browser infrastructure. You own the logic.

What Convex adds

Steel gives you the browser. It doesn't give you storage, querying, or tenant isolation. That's where Convex comes in.

Convex is a real-time backend platform. It has server-side functions and a reactive database. Every write is instantly queryable. Your frontend can subscribe to changes without polling.

It also has a component system. You can install reusable backend modules, including their own database tables, with a single line of config. They're sandboxed and can't touch your app's data unless you explicitly pass things in.

steel-convex-component is one of those modules.

Installing it

npm install steel-convex-component convex
Enter fullscreen mode Exit fullscreen mode

Mount it in your Convex config:

import { defineApp } from "convex/server";
import steel from "steel-convex-component/convex.config";

const app = defineApp();
app.use(steel);

export default app;
Enter fullscreen mode Exit fullscreen mode

Set your Steel API key. Convex runtime doesn't inherit shell env variables so you have to set this explicitly:

npx convex env set STEEL_API_KEY <your_key>
Enter fullscreen mode Exit fullscreen mode

That's the setup.

How it works

Every Steel operation goes through a Convex action. That action calls the Steel API and writes the result back into Convex tables automatically.

const steel = new SteelComponent(components.steel, {
  STEEL_API_KEY: process.env.STEEL_API_KEY,
});

// calls Steel API + writes to Convex sessions table
const session = await steel.sessions.create(ctx, { sessionArgs: {} }, { ownerId });

// reads from Convex only, no Steel API call
const sessions = await steel.sessions.list(ctx, { ownerId }, { ownerId });
Enter fullscreen mode Exit fullscreen mode

Actions write. Queries read from Convex. Your database is always the source of truth.

Multi-tenancy

Every method takes an ownerId. It is enforced at every layer.

await steel.sessions.create(ctx, {}, { ownerId: "user-alice" });
await steel.sessions.create(ctx, {}, { ownerId: "user-bob" });

// only returns alice's sessions
await steel.sessions.list(ctx, { ownerId: "user-alice" }, { ownerId: "user-alice" });
Enter fullscreen mode Exit fullscreen mode

Pass in a user ID or org ID. Isolation is handled for you.

A real example

Here's a price monitor. It creates a session, scrapes a product page, stores the price in Convex, and releases the session.

export const checkPrice = action({
  args: { ownerId: v.string(), url: v.string() },
  handler: async (ctx, args) => {
    const { ownerId, url } = args;

    const session = await steel.sessions.create(
      ctx,
      { sessionArgs: { timeout: 60000 } },
      { ownerId },
    );

    try {
      const result = await steel.steel.scrape(ctx, { url }, { ownerId });
      const price = result?.metadata?.price ?? "unknown";

      await ctx.runMutation(api.prices.record, {
        ownerId,
        url,
        price,
        checkedAt: Date.now(),
      });

      return { price };
    } finally {
      await steel.sessions.release(
        ctx,
        { externalId: session.externalId },
        { ownerId },
      );
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here.

The finally block is not optional. If you skip it and scraping throws, the session leaks. Always release in finally.

The price lands directly in Convex. Your frontend can subscribe to it in real time with no extra API layer.

This same action can run for thousands of users simultaneously. The ownerId handles the isolation.

What's included

Module What it does
sessions Create, refresh, release, list, get
sessionFiles Upload and download files per session
captchas Solve captchas mid-session
profiles Save and reuse browser state
credentials Store and reuse login credentials
extensions Manage browser extensions
steel One-off scrape, screenshot, pdf

The steel.scrape and steel.screenshot utilities are worth calling out. You don't need to manage a full session lifecycle for one-off tasks. These handle it internally.

Observability

Because everything writes to Convex, you can query session history directly from the Convex dashboard. No custom tooling needed.

Filter by status: "live" to see what's running. Filter by ownerId to see one user's history. It's just a table.

The Steel debug URL is also genuinely useful. Open it while a session is running and you can watch the browser in real time. It's the difference between guessing what went wrong and actually seeing it.

Convex session history example

One gotcha

The component uses take() instead of paginate() for list queries. Convex doesn't support paginate() inside components. So cursor-based pagination is not available today.

If you're listing sessions for a tenant with a large history, filter by status or time range rather than paginating through everything.

When to use this

Good fit:

  • Browser automation is a feature of your product
  • Multiple users need isolated sessions
  • You need session history to be queryable
  • You want observability without building it yourself

Probably overkill:

  • You're writing a one-off scraper for yourself
  • You just need to hit one URL and parse the result

Get started

npx convex dev
npx convex env set STEEL_API_KEY <your_key>
npx convex run steelActions:runLifecycle '{"ownerId":"tenant-1"}'
Enter fullscreen mode Exit fullscreen mode

Full setup guide and API reference are in the GitHub repo.

Top comments (1)

Collapse
 
nibzard profile image
Nikola Balic

we should feature this on steel blog