Skip to content

Performance Optimization

When to Use

Optimize forms when buildForm() takes >200ms, AJAX callbacks >300ms, or you have >50 options. Target: <1s load, <2s submit.

WHY Performance Matters:

Slow forms = frustrated users, abandoned submissions, failed conversions, poor SEO (Google penalizes slow sites), server resource exhaustion, hosting cost increase. Target: Forms should load in <1 second, submit in <2 seconds.

Form Rendering Optimization

CRITICAL: What NOT to Put in buildForm()

buildForm() is called on:

  • Initial form display
  • Every AJAX callback
  • Every validation error (form redisplays)
  • Every multi-step navigation
  • Every form alter hook

WHY This Matters: If buildForm() takes 2 seconds, user waits 2 seconds on EVERY AJAX interaction. With 3 AJAX callbacks = 6 seconds total wait time.

Expensive Operations to Avoid in buildForm():

✗ Heavy database queries (>100 rows)
  Impact: 1-5 seconds per query, blocks PHP thread
  Example: Loading 10,000 nodes for select #options

✗ Entity loading in loops (N+1 problem)
  Impact: 100 entities = 100 queries = 3-10 seconds
  Example: foreach ($ids as $id) { Node::load($id); }

✗ Remote API calls
  Impact: 500ms-10s per call, timeout risk, blocks form
  Example: $this->weatherApi->getForecast()

✗ File system operations
  Impact: 50-500ms per file, NFS even slower
  Example: file_exists(), scandir(), file_get_contents()

✗ Complex calculations
  Impact: 100ms-5s depending on complexity
  Example: Recursive algorithms, image processing, PDF generation

✗ External service calls
  Impact: 200ms-30s, failure breaks form
  Example: CRM sync, payment gateway check, geocoding

Performance Thresholds (2025 Research-Backed):

Operation Acceptable Slow Critical
buildForm() total <200ms 200ms-1s >1s
Database query <50ms 50-200ms >200ms
Entity load <10ms 10-50ms >50ms
API call <100ms 100-500ms >500ms
File operation <20ms 20-100ms >100ms

Reference: Drupal Performance Optimization 2025

Optimization Patterns

Pattern 1: Use Autocomplete for Large Option Sets (>50 Items)

WHY: Select with 1000 options = 200KB HTML, 500ms DOM rendering, poor mobile UX, accessibility nightmare.

// WRONG - 10,000 options = massive page load time
$nodes = \Drupal::entityTypeManager()
  ->getStorage('node')
  ->loadByProperties(['type' => 'article']); // Loads 10,000 nodes!
$options = [];
foreach ($nodes as $node) {
  $options[$node->id()] = $node->label(); // 10,000 iterations
}
$form['article'] = [
  '#type' => 'select',
  '#options' => $options, // 200KB+ HTML
];
// Impact: 3-5 second page load, poor mobile experience

// CORRECT - lazy load via autocomplete
$form['article'] = [
  '#type' => 'entity_autocomplete',
  '#target_type' => 'node',
  '#selection_settings' => ['target_bundles' => ['article']],
];
// Impact: <100ms page load, options loaded on demand via AJAX

Decision Threshold:

<20 options: radios/checkboxes (best UX)
20-50 options: select dropdown
>50 options: entity_autocomplete (REQUIRED for performance)
>1000 options: Custom AJAX autocomplete with database search

Pattern 2: Cache Expensive Options (State API or Config)

WHY: Generating options from database/API on every form load = wasted resources. Cache once, reuse thousands of times.

// CORRECT - cache in state system (faster than config for frequently-changing data)
$options = \Drupal::state()->get('mymodule.options');
if (!$options) {
  $options = $this->generateExpensiveOptions(); // Runs once per cache clear
  \Drupal::state()->set('mymodule.options', $options);
}
$form['field']['#options'] = $options;
// Impact: First load 500ms, subsequent loads <10ms

// OR cache in configuration (better for rarely-changing data)
$config = $this->config('mymodule.settings');
$options = $config->get('cached_options');
// Impact: Cached by configuration system, exported with config

// OR cache in custom cache bin (best for frequently-changing data)
$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 Matrix:

Changes: Never → Configuration (exported with site config)
Changes: Daily → State API (simple, no export needed)
Changes: Hourly → Cache API with TTL
Changes: Per-request → Don't cache, use lazy load

Pattern 3: Defer to Validation/Submit

WHY: Entity loads in buildForm() run even when form not submitted. If form displayed 1000 times but submitted 10 times, you loaded entity 990 unnecessary times.

// WRONG - loads entity every time form displays
public function buildForm(array $form, FormStateInterface $form_state) {
  $nid = 123;
  $node = \Drupal::entityTypeManager()
    ->getStorage('node')
    ->load($nid); // Runs on every display, AJAX, error
  $form['info']['#markup'] = $node->label(); // Just displaying label, not modifying
}
// Impact: 10-50ms wasted on every form view

// CORRECT - only load when actually needed
public function buildForm(array $form, FormStateInterface $form_state) {
  $form['node_id'] = [
    '#type' => 'value',
    '#value' => 123, // Store ID only
  ];
}

public function submitForm(array &$form, FormStateInterface $form_state) {
  $nid = $form_state->getValue('node_id');
  $node = \Drupal::entityTypeManager()
    ->getStorage('node')
    ->load($nid); // Loads only on submit
  $node->setTitle($form_state->getValue('title'));
  $node->save();
}
// Impact: Saves 10-50ms on every form display

Pattern 4: Use loadMultiple() Not load() in Loops

WHY: N+1 query problem. Loading 100 entities individually = 100 database queries = 3-10 seconds.

// WRONG - N+1 queries
$nids = [1, 2, 3, ..., 100]; // 100 node IDs
foreach ($nids as $nid) {
  $node = Node::load($nid); // 100 separate queries!
  $options[$nid] = $node->label();
}
// Impact: 3-10 seconds (100 queries × 30-100ms each)

// CORRECT - single query
$nids = [1, 2, 3, ..., 100];
$nodes = \Drupal::entityTypeManager()
  ->getStorage('node')
  ->loadMultiple($nids); // 1 query loads all
foreach ($nodes as $nid => $node) {
  $options[$nid] = $node->label();
}
// Impact: 50-200ms (1 query for all entities)

Form Caching Strategy

When to Enable Form Caching:

Scenario Cache? Why
Multi-step form Yes (REQUIRED) Persist state across steps
Expensive #options generation Yes Avoid regenerating on rebuild
Frequent AJAX rebuilds Yes Reduce database queries
Simple single-step form No Unnecessary overhead
GET method form (search) No Not allowed

Implementation:

public function buildForm(array $form, FormStateInterface $form_state) {
  // Enable caching
  $form_state->setCached(TRUE);

  // Expensive operation runs once, cached
  if (!$form_state->has('expensive_data')) {
    $data = $this->expensiveOperation();
    $form_state->set('expensive_data', $data);
  }

  $form['field']['#options'] = $form_state->get('expensive_data');
}

Cache Storage:

Location: database cache_form table
Expiration: 6 hours default
Cleanup: Cron purges expired entries

Validation Performance

Expensive Validation (Avoid):

public function validateForm(array &$form, FormStateInterface $form_state) {
  // SLOW - remote API call in validation
  $result = $this->remoteApi->validate($value);

  // SLOW - database query for every validate
  $exists = \Drupal::database()->query('SELECT ...');
}

Better Patterns:

// Move remote validation to queue (async)
public function submitForm(array &$form, FormStateInterface $form_state) {
  \Drupal::queue('mymodule.validation')->createItem($data);
}

// Use batch for bulk validation
public function submitForm(array &$form, FormStateInterface $form_state) {
  $batch = [
    'operations' => [[$this, 'batchValidate'], [$items]],
  ];
  batch_set($batch);
}

Good Validation (Fast):

Format checks (regex, strlen)
In-memory comparisons
Required field checks
Range validation (>, <, between)
Type checks (is_numeric, etc.)

Element-Level Optimization

Avoid Nested Loops:

// SLOW - nested entity loads
foreach ($items as $item) {
  $entity = $storage->load($item['id']); // N+1 queries
}

// FAST - batch load
$ids = array_column($items, 'id');
$entities = $storage->loadMultiple($ids); // 1 query

Library Attachment:

// Attach libraries only when needed
if ($complex_widget_needed) {
  $form['#attached']['library'][] = 'mymodule/complex-widget';
}

// Not on every form:
// $form['#attached']['library'][] = 'mymodule/rarely-used';

AJAX Performance

WHY AJAX Performance Matters:

AJAX callbacks feel instant or feel broken - no middle ground. Users expect <300ms response time. Slow AJAX = perceived as broken, users click repeatedly, creates server load cascade.

Performance Thresholds (User Perception):

<100ms: Instant (excellent)
100-300ms: Responsive (acceptable)
300-1000ms: Noticeable lag (poor UX)
>1000ms: Broken (users abandon)

AJAX Anti-Patterns (Avoid):

✗ AJAX on every keyup
  Impact: 50+ requests for typing "hello world", server overload
  Example: Autocomplete without debouncing = DOS yourself

✗ Large render arrays in AJAX responses
  Impact: 100KB response = 500ms+ network time on mobile
  Example: Returning entire form instead of just updated container

✗ Complex AJAX callbacks with DB queries
  Impact: 200ms+ per callback, locks database, blocks other requests
  Example: Loading 50 related entities in AJAX callback

✗ AJAX when #states would work
  Impact: Network roundtrip (200ms) vs instant client-side (<1ms)
  Example: Show/hide based on checkbox = use #states

Optimized AJAX Patterns:

✓ AJAX on change/blur events (not keyup)
  Impact: 1 request per field change vs 10+ per word typed

✓ Return minimal render arrays
  Impact: 5KB response vs 100KB, 50ms vs 500ms render time
  Example: Return only updated container, not entire form

✓ Cache AJAX callback results
  Impact: 5ms cached vs 200ms database query
  Example: Country/state dropdown - cache state list

✓ Use client-side validation first
  Impact: 0ms vs 200ms roundtrip for simple validation
  Example: Email format validation via HTML5 pattern attribute

✓ Debounce AJAX autocomplete
  Impact: 3 requests vs 50+ for typing "example"
  Example: Wait 300ms after last keyup before triggering AJAX

Concrete Example - Autocomplete Optimization:

// WRONG - no debouncing, triggers on every keyup
$form['search'] = [
  '#type' => 'textfield',
  '#ajax' => [
    'callback' => '::searchCallback',
    'event' => 'keyup', // 50+ AJAX requests for one search term!
  ],
];

// CORRECT - debounced, triggers after pause
$form['search'] = [
  '#type' => 'textfield',
  '#ajax' => [
    'callback' => '::searchCallback',
    'event' => 'change', // OR use custom JS with debounce
    'debounce' => 300, // Wait 300ms after last keystroke (Drupal 9.3+)
  ],
];
// Impact: 50+ requests reduced to 1-3 requests

Reference: Performance Optimization Tips 2025

Monitoring and Profiling

Identify Slow Forms:

// Add timing to log
$start = microtime(TRUE);
// ... form building ...
$duration = microtime(TRUE) - $start;
\Drupal::logger('mymodule')->debug('Form build time: @time', [
  '@time' => $duration,
]);

Use Webprofiler Module (Development):

Install devel and webprofiler modules
Enable profiling toolbar
Check form build time, query count

Common Mistakes

  • Loading entities in loops (use loadMultiple)
  • Calling external APIs in buildForm()
  • Not caching expensive #options
  • Using AJAX when simple #states would work
  • Not considering form rebuild impact

See Also