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.
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/..."
}
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
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;
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>
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 });
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" });
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 },
);
}
},
});
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.
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"}'
Full setup guide and API reference are in the GitHub repo.


Top comments (1)
we should feature this on steel blog