# database-js: a PlanetScale driver that speaks HTTP instead of opening a socket

> The PlanetScale serverless JavaScript driver is a Fetch API compatible Vitess/MySQL driver for platforms that require HTTP external connections, such as Cloudflare Workers and Vercel Edge Functions. It offers connection factories, transactions, custom fetch and cast functions, and no query parameter interpolation.

**planetscale/database-js** — A Fetch API-compatible PlanetScale database driver

- Repository: https://github.com/planetscale/database-js
- Website: https://planetscale.com/docs/tutorials/planetscale-serverless-driver
- Stars: 1,206 · Forks: 45
- Language: TypeScript
- License: Apache-2.0
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/planetscale-database-js

## The driver exists because a socket is not available

The PlanetScale serverless JavaScript driver for Vitess/MySQL is described as a Fetch API-compatible driver for serverless and edge compute platforms that require HTTP external connections, such as Cloudflare Workers or Vercel Edge Functions.

That single constraint explains the entire design. A conventional MySQL client opens a TCP socket, negotiates the MySQL protocol, authenticates, and then exchanges packets. None of that is possible in a worker runtime, where the only outbound primitive is an HTTP request to a URL. So the driver cannot be a MySQL client that happens to run in JavaScript. It has to speak HTTP to a server that terminates the MySQL protocol on the other side, which is what the Vitess and PlanetScale setup provides.

The Fetch API compatibility is the design constraint that follows from that. The driver uses the global `fetch` function by default. That is not an implementation detail, it is the interface. It means the driver works in any runtime with a fetch, whether that is Node with its built-in fetch, a worker, or an edge function, and it means the connection is a normal HTTP request as far as the platform is concerned, which is what makes proxies, connection pooling, and HTTP/2 shims possible.

The project also notes the PostgreSQL situation directly, with a tip that if you are connecting to a PlanetScale Postgres database, the Neon serverless driver is supported, with a link to its documentation. So the scope is clear. This is the Vitess/MySQL path, and the Postgres path is a different driver.

The topics list the same set with edge-computing, mysql, serverless, and vercel, and the licence is Apache-2.0, distributed under the Apache 2.0 license with the details in the LICENSE file. The last push to the repository was on 2026-09-24, the same day as the v2.0.0 tag.

## Connecting, with a URL or with a connection factory

The basic usage is a config object and a `connect` call:

```ts
import { connect } from '@planetscale/database'

const config = {
  host: '<host>',
  username: '<user>',
  password: '<password>'
}

const conn = connect(config)
const results = await conn.execute('select 1 from dual')
console.log(results)
```

A single database URL value can be used to configure the `host`, `username`, and `password` values, which is the form you want when the connection details come from an environment variable:

```ts
import { connect } from '@planetscale/database'

const config = {
  url: process.env['DATABASE_URL'] || 'mysql://user:pass@host'
}

const conn = connect(config)
```

The `Client` class is the connection factory, and the documentation gives the reason to use it: to create fresh connections for each transaction or web request handler.

```ts
import { Client } from '@planetscale/database'

const client = new Client({
  host: '<host>',
  username: '<user>',
  password: '<password>'
})

const conn = client.connection()
const results = await conn.execute('select 1 from dual')
console.log(results)
```

This is the piece that matters for a serverless deployment. A worker invocation is a fresh process, and holding a long-lived connection across invocations is not possible and usually not desirable. The factory exists so that the pattern of one connection per request is expressed in the API rather than left to the caller, and the naming makes the lifetime obvious in the code.

It is worth noting what the factory does not promise. It hands you a connection, and how long that connection lives and whether it is reused is a matter of the underlying fetch implementation and whatever pooling you configure, which is why the custom fetch section recommends undici.

## Transactions, and a worked example from a real pattern

The `transaction` function is used to safely perform database transactions. If any unhandled errors are thrown during execution of the transaction, the transaction will be rolled back.

The example is based on the Slotted Counter Pattern, a PlanetScale blog post, and the two statements inside show why that pattern needs a transaction at all:

```ts
import { connect } from '@planetscale/database'

const config = {
  host: '<host>',
  username: '<user>',
  password: '<password>'
}

const conn = connect(config)
const results = await conn.transaction(async (tx) => {
  const whenBranch = await tx.execute(`INSERT INTO branches (database_id, name) VALUES (42, 'planetscale')`)
  const whenCounter = await tx.execute(`INSERT INTO slotted_counters(record_type, record_id, slot, count) VALUES ('branch_count', 42, RAND() * 100, 1) ON DUPLICATE KEY UPDATE count = count + 1`)
  return [whenBranch, whenCounter]
})
console.log(results)
```

The pattern is a sharded counter. You insert a row for the branch, and you increment a counter in a slot chosen at random, with an `ON DUPLICATE KEY UPDATE` to make the increment idempotent under retry. Both statements must succeed or neither should, because a branch row without its counter, or a counter increment without its branch, is a broken state. The transaction callback returns an array of results, so the caller gets both.

The `tx` object passed to the callback has the same `execute` method as a connection, which is what keeps the API small. There is no separate transaction builder, no begin and commit to call, and no way to leave a transaction half open, because the transaction's lifetime is the callback's lifetime. An exception thrown inside the callback rolls the transaction back, which is the behaviour the documentation states and the reason a callback-shaped API is the right shape here.

The pattern also shows something about what this driver is for. The random slot selection and the duplicate-key update are Vitess and MySQL idioms, and the driver exposes them without complaint.

## The custom fetch hook is where the platform differences live

The driver uses the global `fetch` function by default, and you can provide a custom fetch function implementation, for example to configure proxies or connection pooling. The project recommends the undici package, on which Node's built-in fetch is based.

```ts
import { connect } from '@planetscale/database'
import { fetch } from 'undici'

const config = {
  fetch,
  host: '<host>',
  username: '<user>',
  password: '<password>'
}

const conn = connect(config)
const results = await conn.execute('select 1 from dual')
console.log(results)
```

For HTTP/2 there is a separate shim, fetch-h2, whose context provides both a fetch and a `disconnectAll` function:

```ts
import { connect } from '@planetscale/database'
import { context } from 'fetch-h2'
const { fetch, disconnectAll } = context()

const config = {
  fetch,
  host: '<host>',
  username: '<user>',
  password: '<password>'
}

const conn = connect(config)
const results = await conn.execute('select 1 from dual')
console.log(results)
await disconnectAll()
```

The `disconnectAll()` call at the end is a useful detail. An HTTP/2 shim keeps connections alive at the protocol level, and in a long-lived Node process those connections have to be closed explicitly, which is why the example shows it rather than leaving it to a process exit handler.

The point of the hook is that the driver itself is not opinionated about the transport. Everything platform-specific, the proxy configuration, the connection pooling, the protocol version, the timeouts, is delegated to the fetch implementation you supply. That is what lets the same driver code run unchanged in a worker with no fetch options and in a Node service that needs a proxy and a pool.

## No parameter interpolation, and a deliberate cast function

Two details in the API are worth reading closely, because they are the places where the driver deliberately differs from what a MySQL client would do.

The first is the query parameter policy. `execute` does not interpolate query parameters. You pass it a complete SQL string, and calling `execute` with an array or a named-parameter object throws an error. Any values you include in a query must be escaped first, for example with the sql-escaper package:

```ts
import { format } from 'sql-escaper'

const results = await conn.execute(format('select 1 from dual where 1=?', [42]))
```

The reasoning is sound even if the ergonomics are worse. A placeholder syntax has to be translated to whatever the server expects, and the server here is reached over HTTP with the query in the request, so the safest thing a driver can do is send the SQL exactly as written and let the caller decide how to escape. Throwing on an array or a named-parameter object rather than accepting it is what stops a caller who assumed `?` placeholders were supported from shipping an unescaped query.

The second is type casting. Column values are converted to their corresponding JavaScript data types, and this can be customised by providing a `cast` function:

```ts
import { connect, cast } from '@planetscale/database'

function inflate(field, value) {
  if (field.type === 'INT64' || field.type === 'UINT64') {
    return BigInt(value)
  }
  return cast(field, value)
}

const config = {
  cast: inflate,
  host: '<host>',
  username: '<user>',
  password: '<password>'
}

const conn = connect(config)
```

The example converts the 64-bit integer types to `BigInt`, which is the case that matters, because a 64-bit integer does not fit in a JavaScript number without loss. The `field` argument carries the column type, so the cast function can decide per column.

A `cast` function can also be passed per call to `execute`, and if present it overrides the one set by the connection, which is the right precedence for a per-query override. The example in the project summary casts a specific named column, `balance`, to `BigInt` in a query that sums balances.

One more output option completes the API. Rows can be returned as an object or as an array of column values by passing an `as` option to `execute`, with `as: 'object'` giving rows like `[{one: '1', two: '2'}]` and `as: 'array'` giving rows like `[['1', '2']]`.

## The v2.0 release and the packaging details

The release history shows a major version landing on the same day as the last push. The tags are v1.20.1 in March 2026, v1.20.2 in September 2026, and v2.0.0 in September 2026, and the package version in the metadata is 2.0.0.

The packaging changes in the metadata are worth reading, because they are the visible consequences of a 2.0.

The package is now ESM first, with `"type": "module"`, the main and module fields both pointing at `./dist/index.js`, and the types at `./dist/index.d.ts`. The exports map offers an `import` condition pointing at the ESM build and a `require` condition pointing at `./dist/cjs/index.js`, so CommonJS consumers are still served. The build script produces both: it cleans the dist directory, runs `tsc`, then runs `tsc --module commonjs --outDir dist/cjs` and writes a small package.json with the commonjs type into that output directory, which is the standard way to give a nested directory its own module type.

The Node requirement is `>=22`, which is a meaningful floor and a real change for anyone on an older runtime. The keywords list has grown to include lambda alongside the serverless and vercel entries, which suggests the Lambda audience is now an explicit target alongside the edge ones.

The scripts are small and modern. The test script builds first and then runs the Node built-in test runner with tsx to load the TypeScript test files directly, and the tests live in `__tests__/` with a `golden/` directory at the root, which is where fixture outputs for comparison would be kept. The version script writes the package version into a generated `src/version.ts` file, stages it, and rebuilds, so the library can report its own version at runtime.

The repository also has a `renovate.json5` for automated dependency updates, a `CODEOWNERS` file, an `.npmrc`, and a `doc/` directory. The licence is Apache 2.0.

## Conclusion

database-js fits a team deploying a TypeScript service to an edge runtime, where a traditional MySQL socket is not available and the only way out is an HTTP request to the database, and who want the same query API they would use against a normal MySQL server. It is the wrong choice for a Node.js service that can open a TCP connection, and the wrong choice for a PlanetScale Postgres user, for which the project points at the Neon serverless driver instead. Check three things before you adopt it: that your runtime is Node 22 or newer, which the package requires, that you are on Vitess/MySQL rather than Postgres, and that you are willing to escape query parameters yourself, since execute does not interpolate them and passing an array or a named-parameter object throws.

## FAQ

### What is the PlanetScale serverless JavaScript driver and what is it for?

It is a Fetch API-compatible PlanetScale Vitess/MySQL database driver for serverless and edge compute platforms that require HTTP external connections, such as Cloudflare Workers or Vercel Edge Functions. The driver uses the global fetch function by default, and you can supply a custom one, for example undici, to configure proxies or connection pooling.

### Does the database-js driver support query parameters?

No. `execute` does not interpolate query parameters, and calling it with an array or a named-parameter object throws an error. You pass a complete SQL string and escape any values yourself, for example with the sql-escaper package's `format` function.

### How do I run a transaction with database-js?

Use the `transaction` function with an async callback that receives a transaction object with the same `execute` method, and return the results from the callback. If any unhandled error is thrown during the transaction, it will be rolled back. The project summary's example is based on the Slotted Counter Pattern.

### What Node version does the database-js package require?

Node 22 or newer, with the engines field set to `>=22`. The package is ESM first with a `require` export condition pointing at a CommonJS build that the build script produces alongside the ESM one.

## Sources

- [License: Apache-2.0](https://github.com/planetscale/database-js/blob/main/LICENSE)
- [planetscale/database-js on GitHub](https://github.com/planetscale/database-js)
- [Project website](https://planetscale.com/docs/tutorials/planetscale-serverless-driver)
- [README](https://github.com/planetscale/database-js/blob/main/README.md)
- [Releases](https://github.com/planetscale/database-js/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/planetscale-database-js
