Performance Optimization
When to Use
Optimize forms when buildForm() takes >200ms, AJAX callbacks >300ms, or you have >50 options. Target: <1s load, <2s submit.
Decision
| Situation | Optimization | Why |
|---|---|---|
| >50 options in select | entity_autocomplete | 200KB+ HTML, slow DOM rendering |
| Expensive options generation | Cache in State/Config | Generate once, reuse thousands of times |
| Entity loads in buildForm() | Defer to submit | buildForm() runs on every rebuild |
| N+1 query pattern | loadMultiple() | 100 queries → 1 query |
| AJAX on keyup | Debounce or use 'change' | 50+ requests → 1-3 requests |
Critical Rule: What NOT to Put in buildForm()
buildForm() runs on: - Initial display - Every AJAX callback - Every validation error - Every multi-step navigation - Every form alter hook
Avoid: - Heavy database queries (>100 rows): 1-5s per query - Entity loading in loops (N+1): 100 entities = 3-10s - Remote API calls: 500ms-10s, timeout risk - File system operations: 50-500ms per file - Complex calculations: 100ms-5s depending on complexity
Pattern 1: Use Autocomplete for Large Sets
// WRONG - 10,000 options = 5 second page load
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadByProperties(['type' => 'article']); // 10,000 nodes!
$options = [];
foreach ($nodes as $node) {
$options[$node->id()] = $node->label();
}
$form['article'] = [
'#type' => 'select',
'#options' => $options, // 200KB+ HTML
];
// CORRECT - lazy load via autocomplete
$form['article'] = [
'#type' => 'entity_autocomplete',
'#target_type' => 'node',
'#selection_settings' => ['target_bundles' => ['article']],
];
Decision threshold: - <20 options: radios/checkboxes - 20-50 options: select dropdown - >50 options: entity_autocomplete (REQUIRED) - >1000 options: Custom AJAX autocomplete
Pattern 2: Cache Expensive Options
// CORRECT - cache in state system
$options = \Drupal::state()->get('mymodule.options');
if (!$options) {
$options = $this->generateExpensiveOptions(); // Once per cache clear
\Drupal::state()->set('mymodule.options', $options);
}
$form['field']['#options'] = $options;
// OR cache in custom cache bin
$cid = 'mymodule:form:options';
if ($cache = \Drupal::cache()->get($cid)) {
$options = $cache->data;
} else {
$options = $this->generateExpensiveOptions();
\Drupal::cache()->set($cid, $options, time() + 3600); // 1 hour TTL
}
Cache decision: - Never changes: Configuration - Changes daily: State API - Changes hourly: Cache API with TTL - Per-request: Don't cache, use lazy load
Pattern 3: Defer to Validation/Submit
// WRONG - loads entity every display
public function buildForm(array $form, FormStateInterface $form_state) {
$node = Node::load(123); // Runs on every rebuild!
}
// CORRECT - only load when needed
public function buildForm(array $form, FormStateInterface $form_state) {
$form['node_id'] = ['#type' => 'value', '#value' => 123];
}
public function submitForm(array &$form, FormStateInterface $form_state) {
$nid = $form_state->getValue('node_id');
$node = Node::load($nid); // Only on submit
}
Pattern 4: Use loadMultiple()
// WRONG - N+1 queries (3-10 seconds)
$nids = [1, 2, 3, ..., 100];
foreach ($nids as $nid) {
$node = Node::load($nid); // 100 queries!
$options[$nid] = $node->label();
}
// CORRECT - single query (50-200ms)
$nids = [1, 2, 3, ..., 100];
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadMultiple($nids); // 1 query
foreach ($nodes as $nid => $node) {
$options[$nid] = $node->label();
}
Form Caching Strategy
| Scenario | Cache? | Why |
|---|---|---|
| Multi-step form | Yes (REQUIRED) | Persist state across steps |
| Expensive #options | Yes | Avoid regenerating on rebuild |
| Frequent AJAX rebuilds | Yes | Reduce database queries |
| Simple single-step | No | Unnecessary overhead |
public function buildForm(array $form, FormStateInterface $form_state) {
$form_state->setCached(TRUE); // Enable caching
// Expensive operation runs once, cached
if (!$form_state->has('expensive_data')) {
$data = $this->expensiveOperation();
$form_state->set('expensive_data', $data);
}
}
AJAX Performance
User Perception Thresholds: - <100ms: Instant (excellent) - 100-300ms: Responsive (acceptable) - 300-1000ms: Noticeable lag (poor UX) - >1000ms: Broken (users abandon)
AJAX Anti-Patterns: - AJAX on every keyup → Use debounce or 'change' event - Large render arrays → Return minimal containers - Complex AJAX callbacks → Cache results - AJAX when #states works → Use #states (instant)
// WRONG - no debouncing
$form['search'] = [
'#type' => 'textfield',
'#ajax' => [
'callback' => '::searchCallback',
'event' => 'keyup', // 50+ requests!
],
];
// CORRECT - debounced
$form['search'] = [
'#type' => 'textfield',
'#ajax' => [
'callback' => '::searchCallback',
'event' => 'change', // OR custom JS with debounce
'debounce' => 300, // Drupal 9.3+
],
];
Common Mistakes
- Wrong: Loading entities in loops → Right: Use loadMultiple()
- Wrong: External APIs in buildForm() → Right: Defer to submit or use queue
- Wrong: Not caching expensive #options → Right: Cache in State/Config/Cache
- Wrong: AJAX when #states works → Right: #states is instant
- Wrong: No performance monitoring → Right: Use Webprofiler in dev