How to Fix: [DynamiciO] Connection closed when navigating next/link
Next.js connection closes when using next/link with DynamiciO: root cause and fix
If your app works on first load but the browser shows a connection closed error after clicking a next/link route like /A to another page, the problem is usually not the Link component itself. In this DynamiciO reproduction, the failure happens because a server-side runtime object is being torn down or reused incorrectly during client-side navigation, which breaks the response stream for the next request.
The issue is reproducible from the linked repository: view the reproduction project.
Understanding the Root Cause
This bug appears during client-side navigation because next/link does not behave like a full browser refresh. Instead of rebuilding everything from scratch, Next.js requests route data and server-rendered payloads through its internal routing pipeline. If DynamiciO creates a request-bound connection, stream, or server resource and that resource is closed too early, the next navigation can try to read from an already closed channel.
In practice, one of these patterns usually causes the failure:
- A singleton server connection is initialized globally and then closed after the first page render.
- A DynamiciO instance is created in a module scope but depends on per-request state, causing stale references on subsequent navigations.
- A page in the App Router or Pages Router triggers server code during navigation, but the library lifecycle assumes a full request-response completion and closes the underlying stream/socket.
- The app mixes server-only code with code reachable from the client bundle, causing hydration-time or navigation-time resource invalidation.
Why does it show up specifically with next/link? Because soft navigation keeps the application shell alive. That means any incorrectly shared resource can persist across route changes. On the first load, the connection may still be valid. On the next route transition, Next.js fetches new data, but DynamiciO may already have closed the connection that the framework expects to still serve from the server process.
The result is a low-level network failure such as a terminated response, closed socket, or interrupted stream rather than a normal React error boundary message.
Step-by-Step Solution
The fix is to make your DynamiciO usage request-safe and server-only. Do not close shared runtime resources during page rendering, and do not keep a stale per-request object in module scope.
1. Isolate DynamiciO initialization
Create a dedicated server utility that returns a stable instance only if the library supports reuse. Otherwise, create a fresh instance per request without storing request-specific state globally.
// lib/dynamicio-server.ts
import 'server-only'
let dynamicioSingleton: any = null
export function getDynamiciO() {
if (!dynamicioSingleton) {
dynamicioSingleton = createDynamiciO()
}
return dynamicioSingleton
}
function createDynamiciO() {
// Replace with your actual DynamiciO initialization
return {
query: async (...args: any[]) => {
// real implementation
}
}
}
If DynamiciO should not be shared, use a factory instead:
// lib/dynamicio-server.ts
import 'server-only'
export function createDynamiciOForRequest() {
return createDynamiciO()
}
function createDynamiciO() {
return {
query: async (...args: any[]) => {
// real implementation
},
close: async () => {
// close only when the request lifecycle truly ends
}
}
}
2. Remove premature close/dispose calls
Search for any cleanup logic running inside page components, layouts, loaders, or handlers that execute during render/navigation.
// Bad: closes a shared connection during render lifecycle
const io = getDynamiciO()
const data = await io.query('...')
await io.close()
Replace it with code that does not dispose the shared resource after each route render:
// Good: keep shared server resource alive
const io = getDynamiciO()
const data = await io.query('...')
If your library truly needs cleanup, perform it in a controlled server lifecycle, not as part of a route render triggered by next/link.
3. Keep DynamiciO out of client components
Make sure the library is only imported from server components, route handlers, or server-side utilities.
// app/A/page.tsx or pages/A.tsx
import { getDynamiciO } from '@/lib/dynamicio-server'
export default async function PageA() {
const io = getDynamiciO()
const data = await io.query('select * from items')
return <div>{JSON.stringify(data)}</div>
}
Avoid importing it into files marked with 'use client'.
// Bad
'use client'
import { getDynamiciO } from '@/lib/dynamicio-server'
4. If using App Router, force dynamic execution when needed
If the page depends on request-time server state, prevent stale caching behavior from masking connection lifecycle bugs.
// app/A/page.tsx
export const dynamic = 'force-dynamic'
import { getDynamiciO } from '@/lib/dynamicio-server'
export default async function PageA() {
const io = getDynamiciO()
const data = await io.query('select * from items')
return <div>{JSON.stringify(data)}</div>
}
This does not fix bad cleanup logic by itself, but it helps ensure Next.js is not serving a cached result while your underlying server resource has already changed state.
5. Validate with production mode
The original report reproduces in a production build, so test the exact flow after the fix:
pnpm install
npm run build
npm run start
Then navigate using next/link between pages and confirm that:
- the server process stays alive,
- no connection is disposed after first render,
- subsequent route transitions return normal payloads.
6. Add logging around initialization and teardown
If the bug persists, instrument the server helper to confirm whether the connection is recreated, reused, or closed unexpectedly.
// lib/dynamicio-server.ts
import 'server-only'
let dynamicioSingleton: any = null
export function getDynamiciO() {
if (!dynamicioSingleton) {
console.log('[DynamiciO] creating instance')
dynamicioSingleton = createDynamiciO()
} else {
console.log('[DynamiciO] reusing instance')
}
return dynamicioSingleton
}
function createDynamiciO() {
return {
query: async (...args: any[]) => {
console.log('[DynamiciO] query', args)
return []
},
close: async () => {
console.log('[DynamiciO] close called')
}
}
}
If you see close called right before navigation fails, you have confirmed the root cause.
Common Edge Cases
- Hot reload hides the bug in development: development mode often behaves differently from
next start. Always verify against the production build. - Static optimization: a route that appears to work may be statically rendered while another route is dynamic, making the failure look inconsistent.
- Mixed router patterns: combining old Pages Router conventions with App Router data fetching patterns can cause confusing server lifecycles.
- Global mutable state: storing request objects, headers, cookies, or response-bound resources in module scope can poison later navigations.
- Client prefetching: next/link may prefetch data before the visible click. If DynamiciO cannot safely serve concurrent or repeated requests, the first prefetch may alter the state needed by the actual navigation.
- Serverless/runtime differences: if deployed beyond local Node.js, the runtime may create and destroy processes more aggressively. A fragile connection lifecycle will fail more often there.
FAQ
Why does typing the URL directly sometimes work, but clicking next/link fails?
A direct URL load triggers a full request from a fresh browser navigation. next/link uses client-side routing and route data fetching, which reuses more of the app lifecycle. That exposes incorrect resource cleanup and stale server state.
Should I disable next/link prefetch to fix this?
Usually no. Disabling prefetch may reduce how often the problem appears, but it does not solve the underlying issue. The real fix is to make DynamiciO initialization and teardown safe for repeated server requests.
Is this a Next.js bug or a DynamiciO integration bug?
In most cases, it is an integration bug. Next.js is correctly performing soft navigation. The failure happens because the DynamiciO resource lifecycle does not match how Next.js executes server code across navigations.
The reliable fix is simple: keep DynamiciO server-only, avoid closing shared resources during render, and never store request-scoped state in long-lived globals. Once that lifecycle is corrected, next/link navigation should work normally in production.