A recent client project needed to keep a large WooCommerce store in sync with an external inventory system. Thousands of products, most with a dozen or more photos each, refreshed on a nightly full feed.
Pulling every image into the WordPress Media Library is the usual way to handle that. On a catalog this size it puts steady load on the server, so I took a different route. The images go to Cloudflare Images and the import runs through Action Scheduler.
None of this is specific to WooCommerce. The same approach fits any import with a lot of images, whatever post type they attach to.
The work the Media Library takes on
When WordPress sideloads a remote image, it downloads the file, generates every registered thumbnail size with GD or ImageMagick, writes those resized files to disk, and creates an attachment post with its metadata. That is real CPU, disk, and database work for every single image.
One image is nothing. Tens of thousands on a nightly run adds up, and it competes with live traffic for the same server resources. For a catalog this size, there is a good case for keeping the images off the origin server.
Cloudflare Images, by URL
Cloudflare Images stores, resizes, and serves images from Cloudflare’s edge. The part that matters for an importer is that you can upload by URL. You hand Cloudflare the address of the source image, and it fetches and processes the file itself.
The WordPress server never downloads the image, never resizes anything, and never writes a file to disk. The upload is a small request carrying the source URL and a little metadata:
// POST https://api.cloudflare.com/client/v4/accounts/{account}/images/v1
$fields = [
'url' => $source_url, // Cloudflare fetches and processes this
'metadata' => wp_json_encode( $meta ), // your own tags, for later filtering
];
Cloudflare returns an image ID and the variant URLs for the sizes you have configured, and those URLs go on the post. There are no attachments, no growing uploads folder, and no thumbnail regeneration. Resizing and delivery become Cloudflare’s job, served from its CDN.
Docs: Cloudflare Images and upload via URL.
Why I wrote the importer instead of buying one
Off-the-shelf WordPress importers are good general tools, but they are built around the Media Library and around writing post meta one field at a time. Getting direct-to-Cloudflare uploads and batched database writes out of one would have been more work than writing a focused importer.
A purpose-built importer also means no third-party plugin to break on the next WordPress or WooCommerce release. The job is small: map each incoming row to a product, write the fields in batches rather than one at a time, and hand each image to Cloudflare. Keeping it small keeps it easy to reason about.
Action Scheduler does the work in the background
A nightly feed of thousands of products is not something to run inside a single web request. It would time out, and a failure halfway through would leave no clean way to resume. Action Scheduler, the background job runner that ships with WooCommerce and can run on any WordPress site, handles both.
The importer enqueues one job per row, or per small batch, and lets the queue drain on its own:
as_schedule_single_action( time(), 'myplugin_import_row', [ $row_id ], 'catalog-import' );
Each job is isolated, so one bad record does not stop the rest of the run, and failures show up in the WooCommerce admin under Scheduled Actions. Action Scheduler does not retry a failed action on its own, so the importer re-schedules a retry when an upload errors out. Because the work happens in the queue rather than in a request, the front end stays responsive while a large import runs.
Docs: Action Scheduler.
Reading the rate-limit headers
Push thousands of uploads at any API and you will hit its rate limit. Cloudflare returns HTTP 429 when you do, and simply retrying on error just means the import spends its time bouncing off the limit.
A steadier approach is to read what Cloudflare reports on every response. Its API returns rate-limit headers describing how much quota is left and when it refills:
ratelimit: "default";r=1180;t=2
ratelimit-policy: "default";q=1200;w=300
Here r is how many requests are left right now, and q and w are the quota and its window in seconds. I read those on every response and pace the uploads to stay under the limit, slowing down before Cloudflare has to reject anything. If Cloudflare changes the limit, the importer adapts on the next response rather than trusting a hardcoded number that has gone stale.
Docs: Cloudflare API rate limits.
Where the savings come from
Put the pieces together and the origin server is barely working during an import. It is not downloading images, resizing them, writing thumbnail files, or creating attachment posts. It hands Cloudflare a URL and moves on.
Action Scheduler runs those handoffs in the background, and the rate-limit handling keeps the pace as high as Cloudflare allows without tipping into errors. The image processing, the expensive part, never touches the server, so the import stays light and the site stays responsive while it runs.
The trade-offs, because there always are some
This is not free. The images live in Cloudflare, not on your server, so deleting a post or product has to delete its Cloudflare image too, or you leak storage. I wired deletion to remove the matching image for that reason.
You are also depending on Cloudflare for image delivery. For a store already sitting behind Cloudflare that is an easy call, but it is a dependency worth naming.
For a small site, none of this is worth the trouble, and the Media Library is simpler and fine.
For a large, image-heavy import on a regular feed, moving the image processing off the server and running the work through a queue keeps the server a lot quieter than it would be otherwise.







