Bojan Josifoski < wp developer />

Building a Bulk Import System That Handles 10,000+ Rows Without Crashing

November 9, 2025 • Bojan

The Night I Realized PHP Can’t Save You

It was 2:00 AM.
I was testing a bulk import feature that should’ve just worked.
Then I hit “Import” on a CSV with 5,000 rows.

Blank screen.
Fatal error: Allowed memory size exhausted.

I had just crashed my own app.
And all I wanted to do was read a CSV file.

That moment forced me to completely rethink how imports work.
Not just “optimize” it, but rebuild it so it could scale from a few hundred rows to tens of thousands — inside WordPress — without relying on queue workers or external services.

Here’s how I built a bulk import system that can process 10,000+ rows without crashing, using nothing but smart chunking, session management, and a bit of patience.


The Core Rule: Never Read the Whole File

Most CSV imports fail because developers do this:

// ❌ Reads entire file into memory
$rows = file($csv_file);
foreach ($rows as $row) {
    process_row($row);
}

It’s convenient — until you hit a 50 MB CSV and PHP dies quietly.

The fix is simple: stream it.

// ✅ Stream it, don’t load it
$handle = fopen($csv_file, 'r');
while (($row = fgetcsv($handle)) !== false) {
    process_row($row);
}
fclose($handle);

But even streaming isn’t enough.
You can still hit execution timeouts or run out of memory if your processing logic is complex.
That’s where chunking comes in.


Chunked Processing: The Secret Weapon

The idea is to read the file in small, predictable batches — say, 10 rows at a time — process them, save progress, and move on.

Each chunk becomes an independent job.

$handle = fopen($csv_file, 'r');
$chunk_size = 10;
$offset = 0;

while (!feof($handle)) {
    $chunk = read_chunk($handle, $offset, $chunk_size);
    process_chunk($chunk);
    $offset += $chunk_size;
}
fclose($handle);

This one concept — never process everything at once — changes everything.


The Chunked CSV Reader

The real trick is reading specific parts of a CSV without keeping the entire thing in memory.

function parse_csv_chunk($file_path, $offset, $chunk_size) {
    $handle = fopen($file_path, 'r');
    if (!$handle) return [];

    $headers = fgetcsv($handle);
    $data = [];

    // Skip rows before offset
    for ($i = 0; $i < $offset && !feof($handle); $i++) {
        fgetcsv($handle);
    }

    // Read only the next chunk
    for ($i = 0; $i < $chunk_size && !feof($handle); $i++) {
        $row = fgetcsv($handle);
        if ($row) $data[] = array_combine($headers, $row);
    }

    fclose($handle);
    return $data;
}

This approach gives you full control.
You can pause, resume, skip, retry, or parallelize processing later.


Turning It Into a Reliable Pipeline

Once chunking worked, the next step was creating a processing pipeline that handled each chunk through AJAX requests, with full progress tracking.

Each request processes a single batch of rows, updates a transient (a lightweight WordPress key-value store), and sends progress back to the browser.

function process_import_chunk($session_id, $offset, $chunk_size) {
    $session = get_transient('import_' . $session_id);
    $file = $session['file_path'];

    $rows = parse_csv_chunk($file, $offset, $chunk_size);
    $results = [];

    foreach ($rows as $row) {
        $results[] = import_row($row);
    }

    $session['results'][] = $results;
    $session['progress'] = $offset + $chunk_size;
    set_transient('import_' . $session_id, $session, DAY_IN_SECONDS);

    return ['progress' => $session['progress']];
}

On the frontend, it’s just a small loop that keeps calling until done:

for (let i = 0; i < totalChunks; i++) {
  await fetch('/wp-admin/admin-ajax.php?action=process_chunk', {
    method: 'POST',
    body: new URLSearchParams({ offset: i * 10, size: 10 })
  });
  updateProgress(i / totalChunks * 100);
}

Each chunk runs independently. No memory buildup. No long-running requests. No timeouts.


Why This Works

It’s not multithreading. It’s just practical parallelization using short, repeatable tasks.


Handling 10,000 Rows Feels Boring Now

Once the system was stable, 10,000-row imports became routine.

And it all runs inside plain PHP on a shared host. No queues, no workers, no Laravel Horizon, no Redis jobs. Just WordPress, transients, and chunked AJAX.


The Small Details That Make It Work


Lessons Learned

  1. Most “slow” imports are really memory problems.
  2. Reading files in chunks fixes 90% of it.
  3. AJAX chunk loops are enough for distributed processing if designed right.
  4. You don’t need queues for small-scale SaaS tasks.
  5. Simplicity scales better than overengineering.

Why This Matters

Everyone talks about scaling databases, APIs, and caching.
But scaling a basic file import is one of the hardest things to get right.
It’s where PHP hits its limits, and it forces you to actually understand how memory and execution time work.

The next time you see a CSV upload feature, remember — behind it, there’s probably a developer who stayed up all night figuring out how to process 10,000 rows without breaking the server.

About the Author

About the Author

I’m Bojan Josifoski - I’m a WordPress systems engineer who developed and maintained a proprietary WordPress-based framework used by U.S. financial institutions between 2016 and 2025.

← Back to Blog