The package is mssql, the pool belongs to the module and not the request handler, and the types
come from a second package.
Verified on 2026-09-03 against mssql 12.7.0, tedious 20.0.0 and @types/mssql 12.3.0 on
Node 24.15.0, resolved in a running process.
The stack
| Package | What it is |
|---|---|
tedious |
The TDS protocol implementation. Microsoft names it the Node.js driver for SQL Server and contributes to it, but states it is community-supported with no Microsoft support |
mssql |
The wrapper almost every application uses: pooling, parameterised requests and transactions, over tedious |
@types/mssql |
The TypeScript definitions. mssql publishes no types field of its own |
npm install mssql
npm install --save-dev @types/mssql
mssql 12.7.0 accepts tedious ^19.2.2 || ^20.0.0 and declares engines.node >= 18.19.0;
tedious 20.0.0 declares engines.node >= 22. Node 22 or later resolves to tedious 20;
Node 18 quietly stays on 19. Pin the Node version in package.json and CI.
The config object
import sql from 'mssql';
export const config: sql.config = {
server: process.env.AZURE_SQL_SERVER!, // <server>.database.windows.net
port: 1433, // a number, never a string
database: process.env.AZURE_SQL_DATABASE!,
options: { encrypt: true, trustServerCertificate: false },
pool: { max: 10, min: 0, idleTimeoutMillis: 30000 },
};
portmust be a number. The config is not coerced, and a string port fails in a way that reads like a network fault.- At these versions both packages default
encrypttotrueandtrustServerCertificatetofalse.mssqlseeds the pair before handingoptionstotedious, andtedious20 resolves the same two on its own, so a pool built with an emptyoptionsstill resolves to that pair. Earlier majors disagreed, so pin the versions rather than trust the default. At these versions a wrong value is something a person typed. When either may legitimately change isconnect-to-azure-sql. - The
poolvalues above are the defaults. Ten is a floor for a toy and a ceiling for a real API, so setmaxdeliberately. Choosing the number, and why the binding limit is workers rather than sessions, isconnect-to-azure-sql.
The pool is a module-level object
This is the failure this skill exists for.
sql.connect(config) does not open a connection. It constructs a process-wide ConnectionPool
the first time it is called and returns that same pool on every later call.
// db.ts: one pool, created once, awaited by everything else
import sql from 'mssql';
import { config } from './config.js';
export const poolPromise = new sql.ConnectionPool(config).connect();
Every handler does const pool = await poolPromise; and nothing more.
What goes wrong when the pool moves into the handler:
- A new
ConnectionPoolper invocation pays a TCP connect, a TLS handshake and a login every request, and each pool holds its own sockets, so the process ends far over its intended count. sql.close()at the end of a handler destroys the global pool under requests still using it, and against anew sql.ConnectionPoolit closes only the poolsql.connect()built, returnsnulland leaves the sockets open. Usepool.close(), on a shutdown path and nowhere else.- In a serverless host, module scope survives warm invocations and the handler body does not, which is what makes reuse possible.
Microsoft Entra ID without a secret
mssql passes authentication straight through to tedious. The types tedious 20 accepts, from
the shipped connection.d.ts:
authentication.type |
Use it for |
|---|---|
azure-active-directory-default |
Local development and most Azure hosts. Walks the @azure/identity chain |
azure-active-directory-msi-app-service, azure-active-directory-msi-vm |
A managed identity |
azure-active-directory-service-principal-secret |
An app registration and client secret |
azure-active-directory-access-token |
A token the app acquired itself |
token-credential |
An @azure/identity credential supplied directly |
azure-active-directory-password |
Nothing. Microsoft has deprecated this flow |
export const entraConfig: sql.config = {
server: process.env.AZURE_SQL_SERVER!,
database: process.env.AZURE_SQL_DATABASE!,
options: { encrypt: true, trustServerCertificate: false },
authentication: {
type: 'azure-active-directory-default',
options: { clientId: process.env.AZURE_CLIENT_ID }, // user-assigned identity only
},
};
@azure/identity is a dependency of tedious, so there is nothing extra to install. This config
holds no secret and is safe in source control; the SQL authentication variant is not. The identity
still needs a database principal, which is entra-id-auth.
Typed results
interface Person { id: number; first_name: string; }
const result = await pool.request()
.input('id', sql.Int, id)
.query<Person>('SELECT id, first_name FROM person WHERE id = @id');
const rows: Person[] = result.recordset; // recordsets for every set, rowsAffected[0] for counts
The generic on query<T>() is a compile-time assertion, not runtime validation. Nothing checks
the columns against Person; a renamed column yields undefined at run time while the types
compile. If the shape matters, validate the rows or generate the types from the schema.
Use request.input(name, type, value) for every value; concatenating into the SQL text is how
injection gets in.
Check it worked
Ask through mssql itself: what a command line client proves about encryption is true of its own
session, not of the pool your process opened. Save this beside db.ts as check-connection.mjs.
import { poolPromise } from './db.js';
const pool = await poolPromise;
const { recordset: [r] } = await pool.request().query(`
SELECT c.encrypt_option, DB_NAME() AS db,
SUSER_NAME() AS login, USER_NAME() AS db_user
FROM sys.dm_exec_connections AS c WHERE c.session_id = @@SPID`);
console.log(r, 'pool:', pool.size, pool.available, pool.borrowed);
await pool.close();
node check-connection.mjs
| Read | Expect | A wrong value means |
|---|---|---|
encrypt_option |
TRUE |
the session is in clear text; someone overrode options.encrypt |
db |
your database, never master |
database is missing from the config |
login, db_user |
the principal you granted rights to | the credential chain picked another one |
pool.size |
at most pool.max, and flat as requests arrive |
a pool per handler, the failure above |
Two things this check trips over, measured 2026-09-03:
pool.sizethrows before the pool is awaited,TypeError: Cannot read properties of undefined (reading 'numFree'), while@types/mssqldeclares itreadonly size: number. Read it after the await or the check dies on its own instrumentation.- A wrong host or port stops the script first, as
ConnectionError: Failed to connect to <host>:<port>.
sys.dm_exec_connections needs VIEW DATABASE STATE, and on Basic, S0, S1 and elastic pools the
server administrator, the Microsoft Entra administrator or ##MS_ServerStateReader##. Grant it for
the check, then revoke it. CONNECTIONPROPERTY is no substitute: its documented property list has no
encrypt_option.
Do not
- Do not call
sql.connect()or construct a pool inside a request handler, route or serverless function body. - Do not call
pool.close()outside a deliberate shutdown path. - Do not set
trustServerCertificate: trueto clear a certificate error. Readconnect-to-azure-sql. - Do not use
azure-active-directory-password. Microsoft has deprecated the flow it uses. - Do not write retry loops here. Transient faults are one policy for every language, in
connect-to-azure-sql.
References
- Node.js driver for SQL Server:
what
tediousis and what support it carries. Read it when asked which driver is official. - Connect and query with Node.js and mssql: the first-party quickstart, passwordless configuration included. Read it for a working end-to-end sample.
- node-mssql documentation: the config, pool and request API in full. Read it when a config key is in question.
- sys.dm_exec_connections: the columns and the permission the check needs. Read it when the check returns no row.
connect-to-azure-sql: encryption doctrine, retry and transient faults, pool sizing, and the first-connect error on a paused database.- For a runtime without TCP sockets,
mssqlcannot run; use an HTTP-based data or API layer that the runtime supports instead.