-
Notifications
You must be signed in to change notification settings - Fork 2
Utils GetFreePort
The getFreePort utility provides a robust way to find available TCP ports for network services. It's particularly useful for:
- Development Servers: Automatically allocate ports for local development
- Testing: Find available ports for test fixtures without conflicts
- Dynamic Services: Allocate ports for microservices and containerized applications
- CI/CD Pipelines: Prevent port conflicts when running parallel tests
Deno, Bun, and Node.js only.
getFreePorttrial-binds a listener viacompat/net.listen, andlisten()throwsUnsupportedRuntimeErroron Cloudflare Workers (workerd has no way to accept an inbound TCP connection — outboundconnect()still works there) and is unavailable in the browser (no raw TCP at all). Don't callgetFreePortfrom code that also has to run on those two targets.
Finds and returns an available TCP port within the specified range.
Parameters:
-
options(optional): Configuration object-
min(number): Minimum port number (default: 1024) -
max(number): Maximum port number (default: 65535) -
exclude(number[]): Array of ports to exclude from selection
-
Returns: Promise<number> - Resolves to an available port number
Throws: PortError if:
- Invalid port range specified (min/max out of bounds or max < min)
- No free port found within constraints after exhausting attempts
Custom error class for port allocation failures.
class PortError extends Error {
constructor(message: string);
}import { getFreePort } from '@tundralibs/utils';
// Get any available port in safe range
const port = await getFreePort();
console.log(`Server starting on port ${port}`);import { getFreePort } from '@tundralibs/utils';
// Find port for development server
const devPort = await getFreePort({ min: 3000, max: 4000 });
// Find port for production-like testing
const prodPort = await getFreePort({ min: 8000, max: 9000 });import { getFreePort } from '@tundralibs/utils';
// Avoid commonly used ports
const port = await getFreePort({
min: 3000,
max: 5000,
exclude: [
3000, // Common dev server
3306, // MySQL
4200, // Angular dev
5432, // PostgreSQL
],
});import { getFreePort } from '@tundralibs/utils';
// Allocate ports for a microservices cluster
const services = ['api', 'auth', 'data', 'cache'];
const ports = await Promise.all(services.map(async (service) => {
const port = await getFreePort({ min: 8000, max: 9000 });
console.log(`${service} service: port ${port}`);
return port;
}));// Needs a separate install: deno add @tundralibs/compat
import { describe, it } from '@tundralibs/compat/test';
import { getFreePort } from '@tundralibs/utils';
describe('HTTP Server Tests', () => {
it('should start server on free port', async () => {
const port = await getFreePort({ min: 9000, max: 10000 });
// Start your test server on the allocated port
const server = Deno.serve({ port }, () => new Response('OK'));
try {
const response = await fetch(`http://localhost:${port}`);
// ... assertions
} finally {
await server.shutdown();
}
});
});import { getFreePort, PortError } from '@tundralibs/utils';
try {
// This might fail if range is too restrictive
const port = await getFreePort({
min: 80,
max: 100,
exclude: Array.from({ length: 20 }, (_, i) => 80 + i),
});
console.log(`Allocated port: ${port}`);
} catch (error) {
if (error instanceof PortError) {
console.error('Failed to allocate port:', error.message);
// Fallback strategy
const fallbackPort = await getFreePort(); // Use default range
console.log(`Using fallback port: ${fallbackPort}`);
}
}// With Deno's built-in server
import { getFreePort } from '@tundralibs/utils';
const port = await getFreePort({ min: 8000, max: 8100 });
const server = Deno.serve({ port }, (req) => {
return new Response('Hello World');
});
console.log(`Server running at http://localhost:${port}`);The port allocation uses the following strategy:
-
Random Selection: Ports are picked via
crypto.getRandomValuesuniformly within[min, max](not a linear scan), to reduce collision probability - Availability Check: Each candidate port is tested by attempting to bind a TCP listener; excluded picks are skipped without a bind attempt
- Immediate Release: A successful bind is closed immediately and the port number returned — see the race-condition caveat below
-
Bounded Attempts:
maxAttempts = clamp((max - min + 1) * 10, 100, 10000)— a wide range gets proportionally more tries, capped at 10,000; a narrow one still gets at least 100. ExhaustingmaxAttemptsthrowsPortError. -
Exclusion Filtering:
excludeis deduped and checked against the range up front — if every port in[min, max]is excluded, it throws immediately rather than spending attempts
This approach balances randomization (good for parallel processes) with deterministic termination.
No lock between bind-test and real use.
getFreePortcloses its trial listener before returning — there is no OS-level reservation held on the port afterwards. A concurrentgetFreePort()call, or any other process, can bind that same port in the gap before your code actually uses it. See the "Don't assume port stays free" and "Don't reuse ports immediately in parallel" callouts below for the two situations this actually bites.
✅ Use appropriate ranges:
import { getFreePort } from '@tundralibs/utils';
// Development: 3000-5000
const devPort = await getFreePort({ min: 3000, max: 5000 });
// Testing: 9000-10000
const testPort = await getFreePort({ min: 9000, max: 10000 });
// Production-like: 8000-9000
const prodPort = await getFreePort({ min: 8000, max: 9000 });✅ Exclude known service ports:
import { getFreePort } from '@tundralibs/utils';
const port = await getFreePort({
exclude: [3306, 5432, 6379, 27017], // MySQL, PostgreSQL, Redis, MongoDB
});✅ Handle allocation failures gracefully:
import { getFreePort } from '@tundralibs/utils';
let port: number;
try {
port = await getFreePort({ min: 3000, max: 3100 });
} catch {
port = await getFreePort(); // Fallback to default range
}✅ Use in test setup/teardown:
// Needs a separate install: deno add @tundralibs/compat
import { beforeEach } from '@tundralibs/compat/test';
import { getFreePort } from '@tundralibs/utils';
let testPort: number;
beforeEach(async () => {
testPort = await getFreePort({ min: 9000, max: 10000 });
});❌ Avoid privileged ports without permission:
import { getFreePort } from '@tundralibs/utils';
// BAD: Will fail without elevated privileges
const port = await getFreePort({ min: 1, max: 1023 });❌ Don't use overly restrictive ranges:
import { getFreePort } from '@tundralibs/utils';
// BAD: High chance of failure
const port = await getFreePort({ min: 3000, max: 3005 }); // Only 6 ports❌ Don't assume port stays free:
import { getFreePort } from '@tundralibs/utils';
declare function someAsyncOperation(): Promise<void>;
declare function startServer(port: number): void;
// BAD: Race condition
const port = await getFreePort();
await someAsyncOperation(); // Port might be taken now
startServer(port); // Could fail❌ Don't reuse ports immediately in parallel:
import { getFreePort } from '@tundralibs/utils';
// BAD: Potential conflicts
const parallelPorts = [
getFreePort(),
getFreePort(), // Might return same port
getFreePort(),
];
// BETTER: Exclude previously allocated ports
const excludeList: number[] = [];
const ports: number[] = [];
for (let i = 0; i < 3; i++) {
const port = await getFreePort({ exclude: excludeList });
excludeList.push(port);
ports.push(port);
}Benched on Apple M2 Max / Deno 2.9.5 (packages/utils/getFreePort.bench.ts,
awaited):
| Range | Time (avg) |
|---|---|
| Default (1024–65535) | ~31 µs |
| Narrow (9000–9100) | ~34 µs |
A default-range call almost always succeeds on its first bind attempt — each attempt is one real listen-then-close round trip, not a millisecond-scale network operation. Cost only climbs meaningfully when most of the range is excluded or already bound, forcing more attempts before one succeeds.
import { getFreePort } from '@tundralibs/utils';
// Auto-assign ports for dev stack
const config = {
frontend: getFreePort({ min: 3000, max: 4000 }),
backend: getFreePort({ min: 5000, max: 6000 }),
database: getFreePort({ min: 27017, max: 27100 }),
};import { getFreePort } from '@tundralibs/utils';
// Parallel test isolation
const testSuitePort = await getFreePort({ min: 9000, max: 10000 });
Deno.env.set('TEST_PORT', testSuitePort.toString());import { getFreePort } from '@tundralibs/utils';
// Find host port for container mapping
const hostPort = await getFreePort({ min: 30000, max: 32000 });
// docker run -p ${hostPort}:8080 myimagegetFreePort always throws PortError (never a bare Error) for a
bad range or an exhausted search. The exact messages:
| Message | Cause | Resolution |
|---|---|---|
Minimum port must be between 0 and 65535 |
min outside 0-65535 |
Use a valid min
|
Maximum port must be between 0 and 65535 |
max outside 0-65535 |
Use a valid max
|
Maximum port must be greater than minimum port |
max < min |
Correct parameter order |
All ports in range are excluded |
Every port in [min, max] is in exclude
|
Widen the range or trim exclude
|
No free port found in range ${min}-${max} after ${n} attempts |
Range is real but every attempt lost the bind race | Widen range or retry |
-
Compat-Net
listen()- the cross-runtime TCP listenergetFreePorttrial-binds through, and the source of the Workers/browser limitation above - IP Utils - IP address manipulation and validation
- Is In Subnet - Subnet membership checking
- Config - Configuration management for network settings