Skip to main content

Why is Docker Slow on Windows? Fixing WSL 2 Filesystem Performance

 You have a powerful Windows machine, yet your Docker containers feel sluggish. Simple tasks like npm install, compiling Go binaries, or watching for file changes in a React app take exponentially longer than they do on a native Linux machine or a MacBook.

The frustration is real: you execute a command, hear your CPU fans spin up, but the terminal hangs. The bottleneck isn't your processor or RAM; it is almost certainly disk I/O latency caused by the file system boundary.

This guide explains strictly why this architectural bottleneck exists and provides the definitive workflow to achieve near-native Linux performance on Windows using WSL 2.

The Root Cause: The OS Boundary and the 9P Protocol

To solve the speed issue, you must understand the architecture of the Windows Subsystem for Linux 2 (WSL 2).

WSL 2 runs a real Linux kernel inside a lightweight utility VM. It uses a virtual hard disk (VHDX) formatted with the EXT4 file system. However, your Windows host uses NTFS.

The "Cross-OS" Bottleneck

When you mount a folder from Windows into Docker (e.g., C:\Users\You\Project), you are forcing Linux to talk to NTFS.

If your code lives at /mnt/c/Users/..., Docker running inside WSL 2 has to access files across the operating system boundary. To bridge this gap, WSL 2 uses a file server protocol based on 9P.

While 9P is functional, it introduces significant overhead for every single file operation.

Why npm install and Hot-Reload Die

Modern development stacks are metadata-heavy.

  1. Node.js/React: node_modules contains thousands of small files.
  2. PHP/Symfony: Composer relies on massive autoload maps.
  3. Databases: High IOPS requirements for write-ahead logs.

When you run npm install on a Windows-mounted volume:

  1. Linux requests a file write.
  2. The request traverses the 9P protocol bridge.
  3. Windows translates the request to NTFS.
  4. Windows confirms the write.
  5. The confirmation travels back across the bridge to Linux.

Repeat this 50,000 times for a dependency tree, and a 10-second install becomes a 10-minute ordeal.

The Solution: Move Development Inside WSL 2

The fix is not a configuration tweak or a Docker flag. You must change where your data lives.

To get native performance, your source code must reside inside the Linux (EXT4) filesystem, not the Windows (NTFS) filesystem.

By moving your project into the WSL 2 VM, Docker containers mount files directly from the EXT4 partition. This bypasses the 9P bridge entirely, resulting in I/O speeds that match native Linux.

Step 1: Migrate Your Repository

Stop cloning repositories to your Documents or Desktop folder in Windows. Instead, open your WSL terminal (e.g., Ubuntu) and navigate to the Linux home directory.

# In your PowerShell or Windows Terminal, enter WSL
wsl

# Navigate to your Linux home directory (EXT4)
cd ~

# Create a projects directory
mkdir projects && cd projects

# Clone your repository HERE
git clone https://github.com/your-org/your-high-performance-app.git

Step 2: Establish the VS Code Remote Workflow

You might worry about losing the Windows GUI tools. You don't have to. Microsoft's VS Code Remote - WSL extension allows you to run the VS Code UI on Windows while the "backend" (terminal, extensions, debugger, file system access) runs entirely inside Linux.

  1. Install the WSL extension for VS Code.
  2. From your WSL terminal inside your project folder, run:
    code .
    
  3. VS Code will launch on Windows, but the bottom-left corner will say WSL: Ubuntu.

You are now editing files directly on the EXT4 filesystem with zero network latency.

Optimizing Docker Compose for WSL 2

Once your files are in the Linux filesystem, ensure your docker-compose.yml uses relative paths. This ensures the volume bind mount stays within the fast EXT4 layer.

Here is a modern, production-ready docker-compose.yml example optimized for this workflow.

services:
  app:
    # Use a modern node image
    image: node:20-alpine
    working_dir: /app
    
    # BIND MOUNT: 
    # Because 'source' (.) is in WSL (~/projects/app), 
    # this mount is instantaneous.
    volumes:
      - type: bind
        source: .
        target: /app
        # Exclude node_modules to force usage of the container's version
        # preventing OS-architecture conflicts.
      - type: volume
        source: node_modules_vol
        target: /app/node_modules

    ports:
      - "3000:3000"
    
    # Enable polling only if standard notify events fail 
    # (Rarely needed when wholly inside WSL 2)
    environment:
      - CHOKIDAR_USEPOLLING=false

    command: npm run dev

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: securepassword
    # DATABASE PERSISTENCE:
    # Never mount a database data directory to a Windows path.
    # Always use a named Docker volume for high IOPS.
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  node_modules_vol:
  pgdata:

Measuring the Difference: A Benchmark

You can verify the performance gain with a simple Node.js script that performs heavy file I/O operations. Run this script first in a folder mounted from Windows (/mnt/c), and then inside your WSL home (~).

// benchmark-io.js
import fs from 'node:fs/promises';
import path from 'node:path';
import { performance } from 'node:perf_hooks';

const FILE_COUNT = 1000;
const DIR_NAME = 'io-test-temp';

async function runBenchmark() {
  console.log(`Starting I/O Benchmark: Creating ${FILE_COUNT} files...`);
  
  await fs.mkdir(DIR_NAME, { recursive: true });
  
  const start = performance.now();

  // Write operations
  const writePromises = Array.from({ length: FILE_COUNT }, (_, i) => 
    fs.writeFile(path.join(DIR_NAME, `file-${i}.txt`), `Content ${i}`)
  );
  await Promise.all(writePromises);

  const writeEnd = performance.now();
  console.log(`Write Time: ${((writeEnd - start) / 1000).toFixed(2)}s`);

  // Cleanup
  await fs.rm(DIR_NAME, { recursive: true, force: true });
  console.log('Cleanup complete.');
}

runBenchmark().catch(console.error);

Typical Results:

  • Windows Mount (/mnt/c): ~15.0 seconds
  • WSL Native (~/): ~0.4 seconds

Handling Database Persistence

A common mistake developers make, even after moving code to WSL, is mounting database data directories back to the Windows host to "inspect" the files.

Never do this:

# ❌ BAD PRACTICE
volumes:
  - /mnt/c/Users/Dev/pgdata:/var/lib/postgresql/data

Database engines like Postgres and MySQL require high-speed locking and journaling. The latency of the 9P protocol can cause database corruption or extreme timeouts during startup.

Always use Named Volumes: Docker manages named volumes inside the WSL 2 VM automatically.

# ✅ GOOD PRACTICE
volumes:
  - db_data:/var/lib/postgresql/data

Accessing Localhost

When running inside WSL 2, network port forwarding is handled automatically by Windows.

  • If your app listens on port 3000 inside Docker (inside WSL), you can open http://localhost:3000 in your Windows Chrome/Edge browser.
  • You do not need to look up the internal IP address of the WSL VM.

Edge Cases and Pitfalls

1. The Git CRLF Issue

When moving between Windows and Linux filesystems, line endings can break shell scripts.

  • Windows uses CRLF (\r\n).
  • Linux uses LF (\n).

If you clone a repo in Windows and then move it to WSL, your bash scripts might fail with \r command not found. Ensure your git configuration inside WSL enforces LF:

git config --global core.autocrlf input

2. RAM Consumption

WSL 2 can consume significant RAM because it caches files aggressively to speed up I/O. If Vmmem (the WSL process) is eating 100% of your memory, cap it.

Create a file at %UserProfile%\.wslconfig on Windows:

[wsl2]
memory=4GB
processors=2
swap=0

Restart WSL by running wsl --shutdown in PowerShell.

Conclusion

The "slowness" of Docker on Windows is usually a filesystem architecture mismatch, not a lack of hardware power.

By treating the Windows host strictly as a UI layer and moving all file operations—source code, node_modules, and Docker volumes—into the Linux/WSL filesystem, you bypass the expensive protocol translation. The result is a development experience that retains the comfort of Windows while delivering the raw performance of Linux.