A library with 400 items opens instantly. The same site at 6,000 items sits on a blank grid for ten seconds or more, then paints a screen of thumbnails while the laptop fan spins up. Nothing in the WordPress admin tells you which part of that wait was the database and which part was the browser.
The standard advice is to raise the memory limit, install a caching plugin and hope. It fails here because the media screen is not one slow thing. It is four costs stacked on each other: a database query, a metadata prime, a JSON build in PHP, and one HTTP request per visible item. Page caching touches none of them, because upload.php is a logged-in admin screen that no sane cache plugin serves from disk.
What follows takes the screen apart in the order the browser experiences it, with read-only queries that tell you which of the four is hurting your library. Assume you have already deactivated plugins one by one and learned nothing useful.
What the media screen does on every load
Open upload.php and you get the grid, which is what a user sees until they switch views: wp-admin/upload.php reads the media_library_mode user option and falls back to grid. The HTML that arrives is almost empty. The work starts afterwards, in JavaScript.
The media views fire a POST to admin-ajax.php with action=query-attachments, which lands in wp_ajax_query_attachments() in wp-admin/includes/ajax-actions.php. That function builds a WP_Query for post_type=attachment with post_status=inherit (plus private if your role can read private posts), passes the arguments through the ajax_query_attachments_args filter, runs the query, primes the parent post cache with update_post_parent_caches(), and then maps every single result through wp_prepare_attachment_for_js().
The page size matters more than most people expect. In wp-includes/js/media-models.js the attachment query model carries defaultArgs: { posts_per_page: 80 }. Eighty items per request, not twenty. So one grid request means one indexed query over the posts table, one meta prime for eighty IDs, eighty passes through wp_prepare_attachment_for_js(), and eighty image URLs handed to the browser to fetch in parallel.
wp_prepare_attachment_for_js() is not cheap per item either. For each attachment it reads _wp_attachment_metadata, _wp_attachment_image_alt and _wp_attachment_context, resolves the file size, builds an edit link, and loops over the sizes returned by the image_size_names_choose filter. For a user who can edit and delete the file, it also mints three nonces per item: update-post_ID, image_editor-ID and delete-post_ID. That is 240 nonce hashes per grid request before a single pixel appears.
One more detail sits in WP_Query itself. Because no_found_rows defaults to false and the query has a LIMIT, core adds SQL_CALC_FOUND_ROWS to the select and then runs SELECT FOUND_ROWS() afterwards, so MySQL counts the entire matching set on every request just to render a total. On 800 attachments nobody notices. On 80,000 it is a measurable slice of the wait.
The grid and the list are two different programs
Switching between the grid icon and the list icon at the top of the media screen is not a display preference. It swaps one implementation for a completely different one, and that is why they perform so differently.
The grid is a Backbone application talking to admin-ajax.php. Since WordPress 5.8 it does not scroll infinitely by default: apply_filters( 'media_library_infinite_scrolling', false ) in wp-includes/media.php means you get a Load more button instead. The important consequence is that nothing is ever thrown away. Click Load more ten times and the browser is holding 800 img elements and 800 Backbone models at once. The tenth click feels slower than the first even though the server work per click is identical.
The list view is an ordinary WP_List_Table. It paginates properly, 20 rows at a time by default (the upload_per_page user option, adjustable in Screen Options), and it throws the previous page away. Each row renders its thumbnail through wp_get_attachment_image( $attachment_id, array( 60, 60 ), true ), so the images are tiny and the DOM stays small.
The list view carries one cost the grid does not. WP_Media_List_Table::prepare_items() calls wp_edit_attachments_query(), which calls get_available_post_mime_types( 'attachment' ) to work out which entries the file type dropdown should offer. That function runs SELECT DISTINCT post_mime_type FROM wp_posts WHERE post_type = 'attachment' AND post_mime_type != '', and there is no cache around the result, so it executes on every load of the screen. There is no index on post_mime_type, which means the DISTINCT has to look at every attachment row to return a list of six values. Since WordPress 6.4 you can short circuit it by returning an array from the pre_get_available_post_mime_types filter. Grid mode calls only wp_edit_attachments_query_vars() and skips that query entirely.
Attachments are posts, and the cost is in postmeta
Every upload is a row in wp_posts with post_type = 'attachment' and post_status = 'inherit'. That table has an index declared in wp-admin/includes/schema.php as KEY type_status_date (post_type, post_status, post_date, ID), which is exactly the shape of the media query, so the base lookup is usually not your problem. Ten thousand attachments in wp_posts is a well indexed range scan.
The cost is one table over, in wp_postmeta. Every attachment carries at minimum _wp_attached_file, a short string, and _wp_attachment_metadata, a serialised PHP array holding the width, height, filename and mime type of every generated size, the file size, and the image meta scraped from EXIF at upload time. That single value grows with the number of registered image sizes on the site.
Core primes all of it in one query per request through update_meta_cache(), which is the right design, but the query returns every meta row belonging to those eighty IDs and PHP then unserialises each blob. For scale, a library of 784 images on a site with seven registered sizes averages about 1,300 bytes per _wp_attachment_metadata value and peaks at 2,200. Add a page builder and a theme that register another dozen sizes and that string grows in proportion, and it is read, transferred and unserialised eighty times on every Load more click.
This is the mechanism behind the folk wisdom that too many image sizes slow down the media library. It is true, but not for the reason usually given. The files on disk cost you space and backup time, not admin speed. The entry each size adds to a serialised blob that gets read eighty times per screen is what costs admin speed. How one upload becomes many files is worth understanding before you delete any sizes, because some of them are load bearing.
Measuring your own library
Guessing is the reason most people end up at step ten. These queries are read only. Replace wp_ with your actual table prefix, which is very often not wp_.
# How many attachments, and how much serialised metadata sits behind them
wp db query "SELECT COUNT(*) AS attachments
FROM wp_posts WHERE post_type = 'attachment'"
wp db query "SELECT COUNT(*) AS rows_counted,
ROUND(AVG(LENGTH(meta_value))) AS avg_bytes,
MAX(LENGTH(meta_value)) AS max_bytes
FROM wp_postmeta
WHERE meta_key = '_wp_attachment_metadata'"
# Which meta keys hang off attachments, and who put them there
wp db query "SELECT pm.meta_key, COUNT(*) AS rows_counted
FROM wp_postmeta pm
INNER JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.post_type = 'attachment'
GROUP BY pm.meta_key
ORDER BY rows_counted DESC
LIMIT 25"
Read the average blob size first and compare it with the roughly 1,300 bytes above. Several times that means either a long list of registered sizes or an editing history: WordPress writes _wp_attachment_backup_sizes whenever you crop, rotate or flip in the built-in editor, and it only removes that key when you restore the original image.
The meta key census is the fastest way to see what plugins are doing to your attachments. If a key you do not recognise has roughly one row per attachment, something is storing per-file state, and that state is being fetched and unserialised alongside core’s on every grid request.
Measuring your own library is a matter of two numbers and a bit of multiplication, and the result is usually larger than anyone expects.
Enter the attachment count and the sizes you have registered below. It works out how many files that actually is on disk, how many land in the fullest month folder, how many rows and megabytes it puts into postmeta, and how many REST requests a trip through the grid costs. Every assumption in the calculation is visible and editable, because a number you cannot check is not worth having.
Media library weight
A slow media library is nearly always a question of numbers: how many files really lie on the disk, and how many rows the database keeps for each of them. Fill in what your library holds and this works the rest out, one visible assumption at a time. Everything is calculated in this browser tab, nothing is uploaded and nothing leaves the page.
The attachment count stands at the top of the media library. The average size and the average dimensions are what your camera or your phone produces, so a rough figure is enough.
Name, width, height, and the word crop for a hard crop. A 0 means that side is not constrained. The first four are the core sizes every WordPress registers, the rest is a typical theme.
The threshold caps the longest edge of the file WordPress treats as the full size and keeps your upload next to it, so that is two files, not one. Every registered size is then made from the capped file, not from the original.
| Size | Made | Output | Files | Disk | In use |
|---|
Untick a size your theme never outputs. The five numbers above do not move, because those files exist today: the saving turns up in the measures below. Switching a size off in code only stops new files. It deletes nothing that is already there, neither on the disk nor in the metadata, until you regenerate the thumbnails.
| Measure | What it does to these numbers |
|---|
How the estimate is built: a crop size is only made when the source is at least as wide and as tall, a scaled size only when the source is larger on at least one side, so a size bigger than your originals costs nothing. Disk space per size comes from its pixel area against the original, times the compression factor above, which is why a size half as wide costs about a quarter, not half. MB and GB are counted in units of 1024, the way WordPress reports them. Every figure here is an estimate from the fields on this page, not a measurement of your server.
Missing sizes, and why the grid loads originals
This is the single most common reason a media page takes thirty seconds, and it is invisible from the admin because the page eventually looks correct.
wp_prepare_attachment_for_js() only writes a size into the JSON when that size is actually present in $meta['sizes']. It does not check the filesystem and it does not generate anything. It offers the JavaScript three candidates by default, thumbnail, medium and large, and it always appends full, which is the URL of the upload itself. On the JavaScript side, the imageSize() method in wp-includes/js/media-views.js asks for medium, and if that is absent falls back to large, then thumbnail, then full.
If an attachment’s metadata records no sizes at all, every rung of that ladder is missing and the grid renders the original into a tile a few hundred pixels wide. Eighty 4 MB camera JPEGs is 320 MB of image data over the wire for one click of Load more, decoded and downscaled by the browser. The server was never the bottleneck. The network and the compositor were.
Metadata goes missing for boring reasons: files imported by a migration script or an FTP copy plus a direct database insert, uploads made while a size was not yet registered, an optimisation plugin that deleted intermediate files without rewriting the metadata, or a failed upload that wrote the post row before the resize step ran out of memory.
The same gap shows up on the front end, which is how you can confirm it without opening the admin. wp_calculate_image_srcset() returns false when $image_meta['sizes'] is empty, so those attachments ship with no srcset at all and phones get the original too.
# Count image attachments whose metadata records no generated sizes
wp db query "SELECT COUNT(*) AS no_generated_sizes
FROM wp_posts p
LEFT JOIN wp_postmeta pm
ON pm.post_id = p.ID AND pm.meta_key = '_wp_attachment_metadata'
WHERE p.post_type = 'attachment'
AND p.post_mime_type LIKE 'image/%'
AND (pm.meta_value IS NULL OR pm.meta_value = ''
OR pm.meta_value LIKE '%"sizes";a:0:{}%')"
# Same test, but list twenty of them so you can look at the files
wp db query "SELECT p.ID, p.post_title
FROM wp_posts p
LEFT JOIN wp_postmeta pm
ON pm.post_id = p.ID AND pm.meta_key = '_wp_attachment_metadata'
WHERE p.post_type = 'attachment'
AND p.post_mime_type LIKE 'image/%'
AND (pm.meta_value IS NULL OR pm.meta_value = ''
OR pm.meta_value LIKE '%"sizes";a:0:{}%')
ORDER BY p.ID DESC
LIMIT 20"
Both queries scan the join, so run them once, off peak, and not on a loop. If the count comes back in the hundreds or thousands, you have found your problem. The fix is wp media regenerate --only-missing --yes, which touches only the attachments that are missing image sizes rather than reprocessing the whole library. Note that regeneration deletes the old thumbnails it replaces unless you pass --skip-delete, which matters if anything outside your control links to those files. What regeneration actually fixes is worth reading before you run it on 10,000 files.
The mirror image of this problem is metadata that lists sizes whose files were deleted. That case is fast and wrong rather than slow: the browser requests a URL, gets a 404, and shows a broken tile immediately. If your grid is quick but full of holes, you are looking at the opposite failure, and regeneration fixes that too.
Search takes a different path
If browsing is tolerable but typing in the media search box hangs the screen, that is a separate mechanism, not a worse version of the same one.
When a search term is present, wp_ajax_query_attachments() adds __return_true to the wp_allow_query_attachment_by_filename filter so that searching finds files by filename and not only by title. WP_Query honours that by adding a join, visible in wp-includes/class-wp-query.php:
LEFT JOIN wp_postmeta AS sq1
ON ( wp_posts.ID = sq1.post_id AND sq1.meta_key = '_wp_attached_file' )
and a matching (sq1.meta_value LIKE '%term%') condition next to the title and content clauses. A LIKE pattern that starts with a wildcard cannot use an index, so this is a scan of every _wp_attached_file row on the site, joined against the posts table, with the LIMIT applied only after the matching is done. On 50,000 attachments that is genuinely slow and there is no setting that makes it fast. Filtering by date and file type first, then searching within the filtered set, is the practical workaround.
What plugins add
Plugins are the usual suspect and they are usually only part of the answer, so it is worth being precise about what they actually cost.
- A media organiser stores folder membership either as a taxonomy term or as a postmeta row per attachment. The taxonomy version adds a join to the query, the postmeta version adds a row to the prime. Both are small per item and neither is free at 80 items per request.
- An image optimisation plugin typically writes several meta rows per attachment recording original size, new size, savings and status, then renders an extra column or badge in the grid. Those rows show up clearly in the meta key census above, usually as a cluster of keys sharing one prefix.
- The one that surprises people: most optimisation and regeneration plugins process in the background through WP Cron. If a batch is running while you browse, it is competing for the same PHP workers and the same database connections as your admin request. The screen is slow because something else is busy, and the something else is invisible.
Before blaming a plugin, check whether a queue is running. wp cron event list --fields=hook,recurrence,next_run_relative lists the scheduled hooks, and a hook that recurs every minute is a background worker. Query Monitor on upload.php names the slowest query and the plugin that owns it. Deactivating plugins one at a time only tells you something if you measure the same action each time with an empty browser cache.
Memory is a bulk action problem
Browsing the media library rarely exhausts PHP memory. Eighty attachments of metadata is heavy but bounded, and the admin already raises the ceiling: wp-admin/admin.php calls wp_raise_memory_limit( 'admin' ), which applies WP_MAX_MEMORY_LIMIT. On a normal single site where PHP’s own memory_limit is changeable and below 256M, core defines that constant as 256M.
Bulk actions are a different story. Select 200 items, choose Delete permanently, and every one of them runs wp_delete_attachment(), which loads the metadata, unlinks each generated file, removes the meta rows and clears caches, inside a single request that also has to survive your web server’s timeout. Regeneration is worse, because GD holds the decoded bitmap in memory at roughly four bytes per pixel: one 6000 by 4000 photo is about 96 MB before any resizing starts.
That is the point at which the three separate memory ceilings start to matter, and it is also the argument for doing bulk work through WP-CLI instead of the browser. A CLI process gets its own memory limit, has no HTTP timeout above it, and can be resumed.
What helps, in order
The order matters more than the list. Most of the tutorials for this problem start at the bottom.
First, fix missing sizes. If the query above returned a meaningful count, nothing else you do will matter until originals stop being served into thumbnail tiles. This is the only step that can turn thirty seconds into two.
Second, cut the number of registered sizes. Run wp eval 'print_r( wp_get_registered_image_subsizes() );' and read the list honestly. Themes and page builders routinely register sizes that nothing on the front end ever requests. Removing them shrinks the serialised metadata blob for every future upload.
// In a small site-specific plugin, or your child theme's functions.php.
// 1. Stop generating sizes you never use. Check the names first with
// wp_get_registered_image_subsizes(). Affects new uploads only.
add_filter( 'intermediate_image_sizes_advanced', function ( $new_sizes ) {
unset( $new_sizes['some_unused_theme_size'] );
return $new_sizes;
} );
// 2. Halve the work per media grid request. Core asks for 80 at a time;
// 40 makes each Load more click lighter at the cost of more clicks.
add_filter( 'ajax_query_attachments_args', function ( $query ) {
if ( ! empty( $query['posts_per_page'] ) && (int) $query['posts_per_page'] > 40 ) {
$query['posts_per_page'] = 40;
}
return $query;
} );
Two honest caveats on that first filter. It changes nothing for files already uploaded, because their metadata still lists the old sizes and their files are still on disk. Clearing those up is a separate job, and wp media regenerate --delete-unknown is the command that removes thumbnails for sizes that are no longer registered, so take a backup of the uploads folder before you try it. The second caveat is that core builds the responsive srcset from the same recorded sizes, so pruning too aggressively leaves phones fewer candidates and a bigger download.
Third, remove what is not used. A library that grew to 12,000 items usually contains a few thousand attachments nothing links to, plus duplicates from repeated uploads of the same file. Deleting them is the only change that shrinks both tables at once, and it has to be done in the right order, because an attachment row, its files and its meta come apart in ways that leave debris. Working out what an attachment actually is and what is safe to delete is the prerequisite, not an optional extra.
Fourth, work in list mode. Twenty rows and 60 pixel thumbnails, with the previous page discarded. It is not a fix, but it makes the screen usable while you do the first three.
Only then, infrastructure. A persistent object cache genuinely helps the option and meta caches, though it does nothing for the DISTINCT mime type query in list mode, which is not cached at any layer. More PHP memory helps bulk actions and nothing else. A faster database helps the search join. All of it is expensive compared with deleting 4,000 unused files.
The limit core does not solve
There is a size beyond which the media screen is the wrong tool, and no amount of tuning moves it much.
Look at how core registers the post type in wp-includes/post.php: attachment declares no taxonomies at all, and its supports array is just title, author and comments. There is no built-in way to group files. Every folder feature you have ever seen in WordPress is a plugin adding a custom taxonomy on top. Core’s answer to finding one file among 20,000 is a search box that runs an unindexed LIKE, plus a date filter. Why core has no folders is a design decision with real history behind it, not an oversight, but it is still the reason the screen stops scaling.
The grid compounds it by having no page numbers. Load more only moves forward, 80 at a time, and everything you passed stays in the DOM, so reaching item 5,000 takes more than sixty requests and leaves 5,000 image elements alive in the tab. The list view paginates properly but offers no hierarchy and no way to save a query. If your work is browsing rather than uploading, that gap is what you are fighting, not milliseconds, and it is the gap WunderPaint’s media library manager is built for, with non-destructive folders and tags that never move the underlying files, plus duplicate grouping and unused file detection.
Symptom and cause
The grid takes thirty seconds and the network tab shows a few enormous image requests. Those attachments have no generated sizes in their metadata, so the JavaScript fell through to full and the browser is downloading originals. Regenerate with --only-missing.
The first admin-ajax request takes eight seconds before any image starts loading. That is the server, not the files. Look at the size of your _wp_attachment_metadata blobs and at how many meta keys other plugins attach to every attachment.
List view is fine, grid view is slow. The grid requests 80 items where the list requests 20, and it never discards what it has already rendered. Work in list mode and cap the grid page size with ajax_query_attachments_args.
Browsing is acceptable but search hangs. Media search joins wp_postmeta on _wp_attached_file and runs a leading wildcard LIKE, which no index can serve. Narrow by date or file type first.
Slow at some times of day and fine at others. A background queue is running, almost always an optimisation or regeneration plugin using WP Cron. Check the scheduled hooks before you change anything else.
The screen loads but a bulk action dies with a white page. That is a memory or timeout failure in a single request handling hundreds of items, not a media library problem. Move the operation to WP-CLI.
Where that leaves a large library
Almost every slow media library is slow for one of two reasons, and they need opposite responses. Either the browser is being handed original files because the metadata has no sizes to offer it, in which case regeneration turns the screen around in an afternoon. Or the server is doing genuine work per item, in which case the fix is arithmetic: fewer items, fewer registered sizes, fewer meta rows per attachment, fewer items per request. The measurement that tells you which one you have is three SQL queries long and takes about a minute.
What will not help is the advice you have already tried. Page caching does not apply to a logged-in admin screen. Raising the memory limit fixes bulk actions and leaves browsing exactly as it was. A CDN accelerates delivery of files that should not have been requested at that size in the first place. Each of those is a real tool aimed at a real problem, just not this one.
The honest ceiling is worth accepting early. Core treats attachments as ungrouped posts sorted by date, and at tens of thousands of them the admin screen is a list you scroll rather than a library you navigate. At that point the useful question stops being how to make upload.php faster and becomes how much of your media work can move to WP-CLI, and what structure you are willing to add on top of a data model that never had one.