Building a Bulk Import System That Handles 10,000+ Rows Without Crashing
November 9, 2025 • Bojan
November 9, 2025 • Bojan

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.
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.
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 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.
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.
It’s not multithreading. It’s just practical parallelization using short, repeatable tasks.
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.
unset($rows);
gc_collect_cycles();
usleep(100000) (0.1s delay) to avoid hammering the server.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.

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.