ARC frees a class instance the moment its strong reference count drops to zero, using retain and release calls the compiler inserts. A garbage collector finds unreachable objects at runtime instead: it costs pauses or background work, but it also frees reference cycles, which ARC never does.
Strong (the default) keeps the object alive and adds one to its count.
**weak**: adds nothing and becomes nil automatically when the object is freed, so it must be an optional var. Use it for delegates and back references.
**unowned: adds nothing and is not optional; reading it after the object is gone traps. Use it only when the other object always outlives the holder.
Deterministic**: deinit runs right when the last reference goes, so it is a reliable place to stop timers and cancel work, and a print there is the cheapest leak check.
The price: every retain and release is an atomic operation, and two objects that hold each other strongly are never freed. Breaking cycles is your job.
Structs and enums are copied, not reference-counted; ARC reaches them only through the class references they contain.
iOSSenior
iOS Performance & Memory
ARC, retain cycles, Instruments, main thread, image and table optimization
Questions closed
0 / 24
- 0Review
- 6Asked often
Questions
The stack is per-thread memory where allocating is just moving a pointer and everything is freed when the function returns. The heap is shared memory allocated through malloc: slower, and for class instances also reference-counted.
Usually on the stack: local structs, enums and tuples whose size is known and that do not outlive the function.
On the heap: class and actor instances, escaping closure contexts, and the element storage of Array, Dictionary, Data and long String values. The struct itself is small; its buffer lives on the heap.
Value types move to the heap when an escaping closure captures them, when they are boxed into an any Protocol too large for the inline buffer, or when they are stored in a class.
Why it matters: a heap allocation needs a thread-safe allocator plus atomic reference counting, so thousands of small objects in a hot loop cost far more than the same data as structs.
The stack is small (about 1 MB on the iOS main thread, 512 KB on secondary threads), so runaway recursion ends in a stack overflow crash.
A retain cycle is two or more objects holding strong references to each other, so no count ever reaches zero and all of them leak. You break it by making one link weak or unowned, usually the back reference.
Closures: an object stores a closure that captures self strongly: a completion handler kept in a property, a Combine sink stored in self's cancellables, a block-based NotificationCenter observer. Capture [weak self].
Actions on views you own: button.addAction(UIAction { _ in self.reload() }, for: .touchUpInside) on a button the controller owns closes the loop self → button → UIAction → self.
Method references: viewModel.onUpdate = render captures self strongly exactly like a closure, and it is easy to miss in review.
Delegates and parent–child links: declare weak var delegate: SomeDelegate?; the protocol must be class-bound (AnyObject) for weak to compile.
Timers: the run loop retains a scheduled Timer and the timer retains its target or block, so a repeating timer keeps its owner alive until invalidate().
Check: put a print in deinit and pop the screen. If nothing prints, the Memory Graph Debugger shows who still holds the object.
Not every closure needs [weak self]: one that runs and is released, like a UIView.animate block or a non-escaping map, cannot form a cycle.
C and D are cycles; A and B are not. All four capture self strongly, but A and B are released once they have run, so they can at most delay deinit, while C and D are stored by something self owns, which closes the loop.
A: UIKit keeps the animation block only until the animation is set up and releases it. No cycle and no need for [weak self].
B: the session holds the completion handler until the request finishes. If the user closes the screen sooner, the controller is freed only when the response arrives, after reload() has run for a screen nobody sees. Not a leak, but use [weak self] when that late work is unwanted, such as updating UI or sending an analytics event.
C: self → onSave → closure → self. The textbook cycle.
D: self → view → saveButton → UIAction → handler → self. The cycle goes through the view hierarchy, which is why it is easy to miss in review.
Fix: { [weak self] in self?.reload() } for C and D. The question to ask about any closure is who stores it and whether self owns that storer, directly or through other objects.
The run loop retains the scheduled timer and the timer retains its target, self, so closing the screen never drops the controller's count to zero. deinit, the only place that calls invalidate(), never runs: every visit leaks a controller that keeps ticking.
Why: a target–selector Timer holds its target strongly until it is invalidated, and the main run loop keeps a repeating timer alive. The chain run loop → timer → controller does not pass through the timer property, so making that property weak changes nothing.
The cost: each leaked screen keeps its view hierarchy in memory and wakes the CPU every second.
The fix: the block-based scheduledTimer(withTimeInterval:repeats:block:) with [weak self], so the controller is freed on close and its deinit invalidates the timer (Swift 6 also needs MainActor.assumeIsolated in the block). Or start the timer in viewWillAppear and invalidate it in viewDidDisappear.
Catch it: a unit test that calls loadViewIfNeeded(), releases the controller and asserts that a weak reference to it is nil. The Leaks instrument stays silent here: the run loop can still reach the controller, so only the Memory Graph's growing instance count shows it.
A leak is memory the app no longer needs but never frees. The Leaks instrument reports only objects nothing can reach, which in practice means retain cycles, while most real growth is still reachable from a cache, an array or a singleton and shows up only in Allocations and the Memory Graph.
Two kinds: leaked memory (unreachable, a cycle) and abandoned memory (reachable but never used again). In Swift apps the second is the more common one.
Memory Graph Debugger: pause the app in Xcode, look for the purple badges that mark cycles, and check instance counts per class: three ProfileViewController instances after visiting the screen once is the bug. Enable Malloc Stack Logging in the scheme to see where each object was allocated.
Allocations with generations: mark a generation, open and close the screen, mark again. Whatever the new generation still holds survived a round trip that should have freed it.
Typical sources: closures and timers capturing self, a Task looping over a stream that never ends, observers never removed, caches built on a plain Dictionary, singletons holding view controllers, and unbounded buffers: AsyncStream defaults to .unbounded, and a Combine sink requests unlimited demand.
Guard it in tests: addTeardownBlock { [weak sut] in XCTAssertNil(sut) } fails the test when the object outlives it.
Free everything you can rebuild (image caches, decoded data, the heavy state of screens that are not visible), because the system may kill the process next. NSCache does much of this for you: it evicts on its own under memory pressure, while a Dictionary only grows.
Where the warning arrives: didReceiveMemoryWarning() in a view controller, applicationDidReceiveMemoryWarning(_:), or UIApplication.didReceiveMemoryWarningNotification from anywhere. DispatchSource.makeMemoryPressureSource reports warning and critical levels.
Do not rely on it: a warning can come too late, or not at all, before a kill. Keep the footprint bounded all the time and treat the warning as a last chance.
In the background, size decides survival: when memory runs short the system kills background apps first, and a smaller footprint makes yours less likely to be chosen. Drop caches when the app moves to the background.
**NSCache**: thread-safe, evicts automatically, and does not copy its keys. countLimit and totalCostLimit are soft limits, the eviction order is not documented, and keys and values must be objects (NSURL, NSString or a class wrapper).
Need an exact policy, such as a true LRU with a hard cap? Build it: a dictionary plus a doubly linked list gives O(1) lookup and eviction.
The loop fills an autorelease pool that is drained only when the current run-loop turn or work item ends, so every iteration's temporary images and data stay alive until the whole loop returns. Wrapping the loop body in autoreleasepool { } drains them on every iteration.
Who autoreleases: pure Swift objects are freed by ARC at their last use and never touch the pool. The Objective-C code underneath, here UIKit, ImageIO and NSData bridging, still hands back autoreleased temporaries.
When the pool drains: the main thread drains it at the end of each run-loop iteration, a background thread no earlier than the end of its work item. A long loop inside one of them never reaches that point.
The fix: put the body in try autoreleasepool { ... }. It is generic and rethrows, so try works inside. continue does not compile inside a closure, so the guard uses return instead.
Still worth fixing: UIImage(contentsOfFile:) decodes the full-size photo before scaling it. ImageIO's CGImageSourceCreateThumbnailAtIndex downsamples while decoding and needs far less peak memory.
In modern Swift you rarely need autoreleasepool anywhere else: only for long loops over Objective-C-backed APIs on a single thread.
A decoded image costs width × height × 4 bytes however small the file is, so a 12-megapixel photo takes about 48 MB in memory even as a 2 MB JPEG. Downsampling with ImageIO decodes straight to the display size, so the full bitmap never exists.
Decoding happens late: UIImage(data:) and UIImage(contentsOfFile:) keep the compressed bytes and decode at full size on first draw, on the main thread. That is a memory spike and a scroll hitch at once.
Downsample with ImageIO: CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize set to points × screen scale, kCGImageSourceShouldCacheImmediately to decode now on a background thread, and kCGImageSourceShouldCache: false on the source so the full image is not cached.
UIKit shortcut: preparingThumbnail(of:) and the async byPreparingThumbnail(ofSize:) (iOS 15+) do the same for an image you already have, though the source is still read in full.
**UIImage(named:) keeps images in a system cache meant for small, repeated assets. Load large one-off images another way.
Cache the small result**, not the original, in an NSCache with a cost of bytesPerRow × height.
Extensions have a much smaller memory limit than the app, so one full-size photo can kill a share extension or a widget.
The Task holds self strongly for as long as its body runs, and the for await loop never ends because the stream never finishes. Nobody keeps or cancels the task, so it keeps the model alive forever.
Not a classic cycle: the model does not hold the task. The task simply owns self until its closure returns, which here is never.
**What Task { } inherits: priority, actor isolation and task-local values, but not the lifetime of the object that created it. It is not a child of any surrounding task, so nothing cancels it automatically.
[weak self] alone is not enough** if you write guard let self before the loop: that makes self strong for the whole loop. Unwrap inside each iteration.
Own the lifetime: store the Task and call cancel() when the screen goes away. In SwiftUI prefer .task { }, which is cancelled when the view disappears.
Cancellation is cooperative: it only sets a flag. for await, Task.sleep and URLSession react to it; a CPU loop must call Task.checkCancellation().
Reproduce the slow case on a real device with a Release build, record it with the template that matches the symptom, and change one thing at a time, comparing the same trace before and after. The Simulator runs on your Mac's CPU, so its timings mean nothing.
Time Profiler: sampled call stacks. Filter to the main thread, invert the call tree and hide system libraries to find which of your methods costs the time.
Hangs and Animation Hitches: main-thread stalls with their stacks, and frames that missed their deadline, split into commit and render time.
Allocations and Leaks: memory over time with generation marks, and unreachable cycles.
SwiftUI: body evaluations and the state changes that caused them; the instrument was reworked in Xcode 26.
App Launch, Network and Power Profiler: launch phases, request timing and energy cost.
Your own intervals: OSSignposter marks phases of your code so they line up with the system tracks, and Logger messages appear on the same timeline.
Field data first: MetricKit reports launch time, hang rate and memory from real users and tells you which problem to profile.
A hitch is a frame shown late: visible stutter in a scroll or animation. A hang is the main thread blocked for 250 ms or more, so taps get no response. Both usually mean too much work on the main thread; the fix is to move it off and keep each frame within budget.
Budgets: a frame has about 16.7 ms at 60 Hz and 8.3 ms at 120 Hz on ProMotion. A hitch can also come from the render side (shadows, masks, blur) even when your code is fast.
Move work off the main actor: JSON decoding, image decoding, sorting and diffing large collections. Hand only the finished value back to the UI.
No synchronous I/O on main: Data(contentsOf:), large Core Data fetches, DispatchQueue.main.sync or a semaphore wait.
Defer what the first frame does not need: start it in .task or after viewDidAppear, not in init or viewDidLoad.
Swift 6.2 default isolation: in a module that defaults to the main actor, unannotated code runs on main. Mark work that must leave it @concurrent.
Find them: the Hangs and Animation Hitches instruments, Thread Performance Checker in Xcode, and MetricKit's hang rate from real users.
Keep each cell's work inside the frame budget (about 16 ms at 60 Hz and 8 ms at 120 Hz): reuse cells, load and decode images off the main thread at display size, and avoid layout and rendering work that repeats every frame.
Reuse: always dequeue, and cancel the image request in prepareForReuse so an old row's image never lands in a new row.
Prefetching: UICollectionViewDataSourcePrefetching starts loading before a row appears; cancel those loads in cancelPrefetchingForItemsAt.
Images: decode and downsample off the main thread. A full-size UIImage is decoded on first draw, on the main thread.
Updates: apply diffable data source snapshots instead of calling reloadData(), and use reconfigureItems to refresh content without recreating cells.
Layout: flat, cheap cells, self-sizing with sensible estimates, and no layoutIfNeeded() inside cellForRowAt.
Rendering: shadows with an explicit shadowPath, opaque backgrounds, no masks on every cell.
Measure with Animation Hitches and Time Profiler: usually a single expensive call in cell configuration is the culprit.
Offscreen rendering is the GPU drawing a layer subtree into a separate buffer and compositing it back, because it cannot draw it in one pass. Masks, shadows without a shadowPath, group opacity and shouldRasterize trigger it, and the cost repeats every frame for every such layer.
A frame passes through your commit (layout and draw), the render server compositing the layer tree, and the GPU. Offscreen passes cost in the last two, so Time Profiler can show an idle main thread while frames still drop.
What triggers it
**layer.mask and shadows with no shadowPath: the GPU has to render the content first to learn its shape. Ten cards with soft shadows mean ten extra passes per frame.
cornerRadius with masksToBounds over a subtree: rounding a single image or a background colour takes a fast path; clipping several sublayers does not. Pre-round the bitmap instead.
Group opacity**: alpha below 1 on a view with subviews, since allowsGroupOpacity is on by default.
**shouldRasterize: caches a flattened bitmap, which wins only for content that never changes. For anything that animates it re-rasterises every frame.
Related GPU costs
Blending**: every layer that is not opaque is blended with what lies underneath. Set isOpaque = true and a real backgroundColor.
Blur: UIVisualEffectView costs in proportion to its area, so a full-screen blur over a scrolling list is expensive.
How to find it
In the Simulator, Debug → Color Off-screen Rendered paints offending layers yellow and Color Blended Layers paints blended pixels red; on a device the same switches are in Xcode under Debug → View Debugging → Rendering.
Animation Hitches splits hitch time into commit and render; long render phases with a quiet main thread point here.
Symbolicate the report with the dSYM of that exact build, then read the exception type and the crashed thread's stack. The exception type tells you what kind of bug it is before you even open the code.
Where reports come from: the Xcode Organizer (from users who share analytics), TestFlight, MetricKit's MXCrashDiagnostic, or a crash SDK such as Crashlytics.
dSYM: the debug symbols stripped from the release binary. Every build has a UUID and only the dSYM with the same UUID can symbolicate it, so keep every archive and upload dSYMs to your crash service from CI.
**EXC_BREAKPOINT (SIGTRAP)**: a Swift runtime trap: force-unwrapping nil, an index out of range, fatalError, arithmetic overflow, reading a freed unowned reference.
**EXC_BAD_ACCESS (SIGSEGV/SIGBUS): invalid memory access: use-after-free, unsafe pointers, data races.
EXC_CRASH (SIGABRT)**: an uncaught Objective-C exception or an explicit abort(). The Last Exception Backtrace section shows where it was thrown.
**SIGKILL with a termination reason**: the system killed the app (watchdog, memory, thermal). The top frame is not the bug.
Then rank crashes by affected users, reproduce on the same OS version and device class, and lock the fix in with a test.
The app touched memory it must not: a freed object, a dangling pointer or an unmapped address. The crashing line is often just where the damage was noticed, so you use tools that stop the app at the moment of the bad access.
Rare in safe Swift, which traps with EXC_BREAKPOINT instead. Suspect Unmanaged, unowned(unsafe), an UnsafePointer used outside its withUnsafe… closure, C and Objective-C interop, and data races that corrupt shared state.
Address Sanitizer (scheme → Diagnostics) stops at the exact use-after-free or buffer overflow and prints where the memory was allocated and freed. It slows the app about 2–5×, so run it in debug and in a CI test pass.
Thread Sanitizer: reports data races with both stacks. It cannot run together with Address Sanitizer.
Zombie Objects: freed Objective-C objects become zombies and log "message sent to deallocated instance" instead of crashing somewhere random.
Malloc Scribble and Guard Malloc: fill or protect freed memory so a random crash becomes a repeatable one.
Exception breakpoint: for a SIGABRT from an Objective-C exception it stops at the throw, not at abort deep inside main.
The watchdog killed it: the app did not finish a lifecycle transition, such as launch or returning to the foreground, within its time limit, so the system sent SIGKILL and stamped the report 0x8badf00d ("ate bad food").
The limit: about 20 seconds from launch to the first frame, less for resuming or suspending a scene. The Termination Reason names FRONTBOARD and the exhausted time allowance, so you can tell which transition timed out.
Read Thread 0, not the top frame: look for semaphore_wait, __psynch_mutexwait, dispatch_sync or a socket read. It is a blocked main thread, not a crash in that frame.
Usual causes: a synchronous network call, a DispatchSemaphore that makes async code look synchronous, DispatchQueue.main.sync from a queue the main thread is already waiting on, a Core Data or SwiftData migration during launch, or an SDK doing any of these in didFinishLaunchingWithOptions.
Neighbouring codes: 0xdead10cc means the app held a file or SQLite lock while being suspended; 0xc00010ff is a thermal kill.
Catch it before users do: Thread Performance Checker flags blocking calls on the main thread at runtime, the Hangs instrument shows each stall with its stack, and MetricKit counts watchdog exits in MXAppExitMetric.
Reproduce it the way users hit it: cold launch, empty caches and a network that answers in three seconds rather than thirty milliseconds.
Pick a few user-visible numbers and measure them in two places: performance tests in CI on a fixed device catch a regression before release, and MetricKit plus the Xcode Organizer show what real devices experience after it.
Choose a handful of budgets: cold launch to first frame, hang rate, hitch rate on the main scroll, peak memory on the heaviest screen, download size. Measuring everything means watching nothing.
XCTest metrics: measure(metrics:) with XCTApplicationLaunchMetric, XCTMemoryMetric, XCTOSSignpostMetric or XCTClockMetric records a baseline and fails when a build drifts past the allowed deviation.
Stable numbers: run those tests on one physical device model with a Release build. A Simulator or a busy shared CI machine produces noise, and a flaky performance test gets muted within a week.
Xcode Organizer: launch time, hang rate, memory, disk writes, energy and terminations per app version, from users who share analytics.
MetricKit: MXMetricPayload arrives about once a day with histograms; MXDiagnosticPayload carries hang, crash and CPU-exception stacks. Upload both to slice by device, OS version and feature flag.
Percentiles, not averages: the median user never sees what the slowest tenth of devices lives with every day, so alert on p90 and p95.
Your app only describes each frame: at the end of a run-loop turn it commits the changed layer tree to the render server, a separate process that renders it on the GPU and runs the animations. An animation already handed over keeps running even while your main thread is blocked.
Why the spinner survives
UIActivityIndicatorView and UIView.animate produce CAAnimations that the render server interpolates by itself. What freezes is everything the main thread owns: touch handling and new commits. A CADisplayLink callback runs on your run loop, so it stops with the app.
A hang (250 ms or more without processing input) therefore looks like dead buttons, not a frozen picture. Judge it with the Hangs instrument, not by eye.
The render loop
Event: touches, timers and callbacks change view state on the main thread.
Commit: at the end of the run-loop turn the implicit CATransaction commits: layout (layoutSubviews), display (draw(_:)), image decoding, then the layer tree is encoded and sent to the render server. All changes in one run-loop turn commit together: set frame three times and only the last value is drawn.
Render: the render server decodes the tree and draws it on the GPU, including any offscreen passes.
Display: the finished frame appears at a later vsync. Each stage must fit in one frame time: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz.
Where frames get lost
A slow commit (your main thread) or a slow render (the GPU) both cause a hitch; the animations question covers the two kinds and how to measure them.
The gauge. The system's memory limit applies to phys_footprint (all dirty plus compressed memory of the process), while Allocations mostly shows what went through malloc and the object runtimes, and misses large memory regions that are not malloc heap.
Dirty memory is anything the app wrote to (heap, decoded bitmaps, stacks) and counts in full. Clean memory is file-backed and can be re-read, so the kernel can evict it: the app binary, memory-mapped files. It does not count until you write to it.
Compressed memory is dirty memory that the kernel squeezed under pressure. It still counts, at its compressed size, and touching it costs CPU to decompress. A footprint that falls without you freeing anything is usually just compression.
Where the missing 390 MB usually is: IOSurface and IOKit memory shared with the GPU: layer backing stores, decoded images on screen, CVPixelBuffers, Metal textures. In a photo grid that is nearly all of the gap. The VM Tracker instrument, vmmap --summary and the footprint tool break the process down by region.
Measure the number that kills you: os_proc_available_memory() returns the headroom left before the limit, and task_info with TASK_VM_INFO returns phys_footprint from inside the app.
Map instead of read: Data(contentsOf: url, options: .mappedIfSafe) gives clean, evictable pages, while a plain Data(contentsOf:) copies the file into dirty memory.
Take the number from a Release build on a device: Malloc Stack Logging and sanitizers inflate a debug session. Then confirm it against peak memory from MetricKit.
Measure cold launch to the first frame on real devices, then cut the two phases separately: the work dyld and the runtime do before main, and your own code from main until the first screen is drawn. Apple's target is a first frame in about 400 ms.
Before main
Dynamic frameworks: each one is loaded, bound and signature-checked by dyld on every cold launch. Link static libraries instead, or use mergeable libraries (Xcode 15+) to keep dynamic frameworks in Debug and merge them in Release.
Static initialisers: Objective-C +load methods and C++ static constructors run before main. Swift globals and static let are lazy and run on first access, so they are safe here.
From main to the first frame
didFinishLaunchingWithOptions, the scene setup and the first screen's body or viewDidLoad are where most avoidable time hides: SDK initialisation, synchronous Keychain or database reads, parsing a large config, warming caches.
Defer every SDK the first screen does not need, make services lazy, and start the rest in a .task or a background task after the first frame.
Show cached content instead of waiting for the network.
Measure
The App Launch template in Instruments shows both phases on one timeline, and OSSignposter intervals mark your own steps.
MetricKit's MXAppLaunchMetric and the Xcode Organizer give real users' launch times; XCTApplicationLaunchMetric catches regressions in CI.
Measure cold launches after a reboot: a warm launch reuses pages already in memory and flatters you. The system may also prewarm the process before the user taps the icon, so time measured from process start is not what the user waited.
Energy goes into waking the hardware (the cellular radio, GPS, the GPU and the CPU) more than into running code, so the fixes are about doing work less often and in batches. Measure on a device first, because the cause is usually one subsystem left running.
Measure: the Power Profiler instrument (Xcode 26) breaks power down by CPU, GPU, display and networking, the Xcode Organizer shows energy reports from users, and MetricKit sends CPU time, GPU time, location activity and cellular conditions.
Batch network traffic: the cellular radio stays powered for several seconds after each transfer, so fifty small requests cost far more than one. Buffer analytics and send them in batches or when the app goes to the background.
Never poll: a timer hitting the server every thirty seconds is the classic drain. Use push notifications or one long-lived connection.
Let the system choose the moment: a background URLSession with isDiscretionary = true for large transfers, and BGAppRefreshTask or BGProcessingTask for maintenance work.
Location: usually the single biggest drain. Request the coarsest accuracy that works, prefer significant-change or region monitoring to continuous updates, and stop updates when the screen goes away.
Stop drawing when nothing changes: endless animations, a CADisplayLink left running at 120 Hz, or a video looping behind a static screen keep the GPU and display busy.
Respect Low Power Mode and heat: pause prefetching and non-essential animation when ProcessInfo.processInfo.isLowPowerModeEnabled is true, and reduce work when thermalState rises.
Measure what users actually download (App Store Connect's per-device sizes or Xcode's App Thinning Size Report), then attack the largest part, usually assets and then third-party code. The .ipa on your Mac contains every device variant and is not that number.
Assets: keep images in asset catalogs so app thinning ships only the scale each device needs, prefer SF Symbols and vector images to bitmap sets, re-encode oversized artwork, and look for bundled videos, fonts and duplicate images.
Download content later: move tutorials, level packs and rarely used media out of the install with Background Assets. Apple can host the asset packs since iOS 26, and presents Background Assets as the replacement for On-Demand Resources.
Code: keep dead code stripping on, strip symbols in Release, and build modules where speed does not matter with -Osize. Heavy generic specialisation and large @inlinable APIs also add code.
Dependencies: a linker map (LD_GENERATE_MAP_FILE = YES) shows bytes per module. One SDK that brings its own networking, analytics or ML model can cost tens of megabytes, and every dynamic framework adds its own metadata; static linking or mergeable libraries cut both size and launch time.
Guard it in CI: record the thinned size for each build and fail when it passes a budget, because size grows one dependency at a time. It matters: on cellular the App Store asks for confirmation above 200 MB by default, and install conversion drops as size grows.
Thread explosion happens when work dispatched to a concurrent queue blocks, so GCD starts another thread for the next item, and then another. Dozens of mostly blocked threads then waste memory and CPU on context switches instead of doing work.
Classic trigger: DispatchQueue.global().async in a loop where each item blocks on I/O, a lock or a semaphore.
The cooperative pool: Swift Concurrency runs tasks on a pool limited to about the number of CPU cores. That works only because tasks never block: at an await they suspend and the thread takes another task. Blocking inside a task breaks that contract and can stall the pool; see the Swift Concurrency question on blocking the cooperative pool.
Bound the parallelism: a task group that adds new tasks only as old ones finish keeps, say, six downloads in flight instead of five hundred.
Diagnose: the Swift Concurrency instrument shows tasks and actors over time; Time Profiler's thread list shows a pile of GCD threads.